From 272a34866e2c966c6839bcd6b99f3b727ccb884c Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Fri, 4 Sep 2026 14:28:45 +0200 Subject: [PATCH] feat: add Home Key access token provider --- README.md | 34 ++- src/access-token-provider.ts | 402 +++++++++++++++++++++++++++++ src/index.ts | 1 + test/access-token-provider.test.ts | 252 ++++++++++++++++++ test/node-smoke.mjs | 2 + test/public-api.test.ts | 1 + 6 files changed, 679 insertions(+), 13 deletions(-) create mode 100644 src/access-token-provider.ts create mode 100644 test/access-token-provider.test.ts diff --git a/README.md b/README.md index 481e636..a3afe00 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ client. It is currently an alpha while the Miakapp 3.5 relay is being deployed. ## Requirements - Node.js 22.9 or newer -- An application backend able to issue short-lived coordinator access tokens +- A Miakapp Home Key or another approved short-lived access-token provider - A Miakapp relay implementing wire protocol 1.0 MiakAPI is server-side software. Do not ship coordinator credentials, Home Keys, @@ -31,22 +31,18 @@ import { ApplicationCallError, EventDirection, createCoordinator, + createHomeKeyAccessTokenProvider, } from 'miakapi'; +const homeKey = process.env.MIAKAPP_HOME_KEY; +if (homeKey === undefined) throw new Error('MIAKAPP_HOME_KEY is required'); + const coordinator = createCoordinator({ name: 'home-assistant', - accessTokenProvider: { - async getAccessToken({ coordinatorName, reason, signal }) { - const response = await fetch('https://example.test/miakapp/token', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ coordinatorName, reason }), - signal, - }); - if (!response.ok) throw new Error('Access token request failed'); - return response.json(); - }, - }, + accessTokenProvider: createHomeKeyAccessTokenProvider({ + exchangeEndpoint: 'https://control.miakapp.com/v1/access-tokens:exchange', + homeKey, + }), }); coordinator.configure({ @@ -87,6 +83,18 @@ const session = await coordinator.start(); console.log('Ready in generation', session.generation); ``` +The Home Key provider makes exactly one exchange request for each initial, +reauthentication, or reconnect demand from the SDK. It sends the Home Key only +to the configured HTTPS control-plane endpoint, rejects redirects and open or +overlong responses, and returns only the relay URL, compact access token, and +expiry to the coordinator core. It performs no independent retry; the +coordinator's single bounded reconnect schedule remains authoritative. + +Keep the Home Key in the trusted coordinator backend. Do not place it in a web +bundle, browser storage, logs, URLs, or relay configuration. Applications with a +different approved credential store may continue to implement +`AccessTokenProvider` directly. + `configure` supplies all five declaration slices as one desired snapshot. The coordinator becomes `ready` only after the relay acknowledges them in order. A later declaration call replaces its complete slice and temporarily returns the diff --git a/src/access-token-provider.ts b/src/access-token-provider.ts new file mode 100644 index 0000000..cdc7d13 --- /dev/null +++ b/src/access-token-provider.ts @@ -0,0 +1,402 @@ +import type { + AccessToken, + AccessTokenProvider, + AccessTokenRequest, +} from './api.js'; + +const MAXIMUM_RESPONSE_BYTES = 65_536; +const MAXIMUM_JSON_DEPTH = 8; +const MAXIMUM_JSON_VALUES = 128; +const MAXIMUM_JSON_STRING_BYTES = 16_384; +const MAXIMUM_JSON_OBJECT_ENTRIES = 32; +const MAXIMUM_JSON_ARRAY_ITEMS = 32; +const MAXIMUM_ACCESS_TOKEN_BYTES = 8_192; +const MAXIMUM_ACCESS_TOKEN_LIFETIME_MS = 330_000; +const HOME_KEY = /^mhk1_([A-Za-z0-9_-]{22})_([A-Za-z0-9_-]{43})$/; +const BASE64URL = /^[A-Za-z0-9_-]+$/; +const COORDINATOR_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +const CONTROL_CHARACTER = /\p{Cc}/u; +const UTF8 = new TextEncoder(); + +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + +export interface HomeKeyAccessTokenProviderOptions { + readonly exchangeEndpoint: string; + readonly homeKey: string; + readonly fetch?: (input: string, init: RequestInit) => Promise; +} + +function exchangeFailure(): never { + throw new Error('Miakapp access-token exchange failed'); +} + +function exactRecord( + value: unknown, + required: readonly string[], + optional: readonly string[], +): Readonly> { + if (value === null || Array.isArray(value) || typeof value !== 'object') return exchangeFailure(); + const keys = Object.keys(value); + const allowed = new Set([...required, ...optional]); + if (required.some((key) => !Object.hasOwn(value, key)) + || keys.some((key) => !allowed.has(key))) return exchangeFailure(); + return value as Readonly>; +} + +function canonicalExchangeEndpoint(value: unknown): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 2_048) { + return exchangeFailure(); + } + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return exchangeFailure(); + } + if (parsed.protocol !== 'https:' + || parsed.username !== '' + || parsed.password !== '' + || parsed.search !== '' + || parsed.hash !== '' + || parsed.pathname !== '/v1/access-tokens:exchange' + || parsed.href !== value) return exchangeFailure(); + return value; +} + +function decodeCanonicalBase64URL(value: string, bytes: number): boolean { + if (!BASE64URL.test(value)) return false; + const decoded = Buffer.from(value, 'base64url'); + return decoded.byteLength === bytes && decoded.toString('base64url') === value; +} + +function validHomeKey(value: unknown): { value: string; keyId: string } { + if (typeof value !== 'string') return exchangeFailure(); + const match = HOME_KEY.exec(value); + if (match === null + || match[1] === undefined + || match[2] === undefined + || !decodeCanonicalBase64URL(match[1], 16) + || !decodeCanonicalBase64URL(match[2], 32)) return exchangeFailure(); + return { value, keyId: match[1] }; +} + +function hasUnpairedSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const unit = value.charCodeAt(index); + if (unit >= 0xd800 && unit <= 0xdbff) { + const following = value.charCodeAt(index + 1); + if (index + 1 >= value.length || following < 0xdc00 || following > 0xdfff) return true; + index += 1; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + return true; + } + } + return false; +} + +function parseResponseJson(input: Uint8Array): JsonValue { + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(input); + } catch { + return exchangeFailure(); + } + let index = 0; + let values = 0; + const skipWhitespace = (): void => { + while (index < text.length) { + const code = text.charCodeAt(index); + if (code !== 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) break; + index += 1; + } + }; + const parseString = (): string => { + if (text[index] !== '"') return exchangeFailure(); + const start = index; + index += 1; + let escaped = false; + while (index < text.length) { + const character = text[index]; + if (!escaped && character === '"') { + index += 1; + let decoded: unknown; + try { + decoded = JSON.parse(text.slice(start, index)) as unknown; + } catch { + return exchangeFailure(); + } + if (typeof decoded !== 'string' + || hasUnpairedSurrogate(decoded) + || UTF8.encode(decoded).byteLength > MAXIMUM_JSON_STRING_BYTES) { + return exchangeFailure(); + } + return decoded; + } + if (!escaped && character === '\\') escaped = true; + else escaped = false; + index += 1; + } + return exchangeFailure(); + }; + const parseNumber = (): number => { + const match = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(text.slice(index)); + if (match === null) return exchangeFailure(); + index += match[0].length; + const number = Number(match[0]); + if (!Number.isFinite(number)) return exchangeFailure(); + return number; + }; + const parseValue = (depth: number): JsonValue => { + if (depth > MAXIMUM_JSON_DEPTH) return exchangeFailure(); + values += 1; + if (values > MAXIMUM_JSON_VALUES) return exchangeFailure(); + skipWhitespace(); + const character = text[index]; + if (character === '"') return parseString(); + if (character === '-' || (character !== undefined && character >= '0' && character <= '9')) { + return parseNumber(); + } + if (text.startsWith('true', index)) { + index += 4; + return true; + } + if (text.startsWith('false', index)) { + index += 5; + return false; + } + if (text.startsWith('null', index)) { + index += 4; + return null; + } + if (character === '[') { + index += 1; + const result: JsonValue[] = []; + skipWhitespace(); + if (text[index] === ']') { + index += 1; + return result; + } + while (true) { + if (result.length >= MAXIMUM_JSON_ARRAY_ITEMS) return exchangeFailure(); + result.push(parseValue(depth + 1)); + skipWhitespace(); + if (text[index] === ']') { + index += 1; + return result; + } + if (text[index] !== ',') return exchangeFailure(); + index += 1; + skipWhitespace(); + } + } + if (character === '{') { + index += 1; + const result = Object.create(null) as { [key: string]: JsonValue }; + const keys = new Set(); + skipWhitespace(); + if (text[index] === '}') { + index += 1; + return result; + } + while (true) { + if (keys.size >= MAXIMUM_JSON_OBJECT_ENTRIES) return exchangeFailure(); + const key = parseString(); + if (keys.has(key) || key === '__proto__' || key === 'prototype' || key === 'constructor') { + return exchangeFailure(); + } + keys.add(key); + skipWhitespace(); + if (text[index] !== ':') return exchangeFailure(); + index += 1; + result[key] = parseValue(depth + 1); + skipWhitespace(); + if (text[index] === '}') { + index += 1; + return result; + } + if (text[index] !== ',') return exchangeFailure(); + index += 1; + skipWhitespace(); + } + } + return exchangeFailure(); + }; + + skipWhitespace(); + const parsed = parseValue(1); + skipWhitespace(); + if (index !== text.length) return exchangeFailure(); + return parsed; +} + +async function boundedResponseBody(response: Response): Promise { + const contentLength = response.headers.get('content-length'); + if (contentLength !== null + && (!/^(?:0|[1-9][0-9]*)$/.test(contentLength) + || Number(contentLength) > MAXIMUM_RESPONSE_BYTES)) return exchangeFailure(); + if (response.body === null) return exchangeFailure(); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const item = await reader.read(); + if (item.done) break; + size += item.value.byteLength; + if (size > MAXIMUM_RESPONSE_BYTES) { + await reader.cancel().catch(() => undefined); + return exchangeFailure(); + } + chunks.push(item.value); + } + } catch { + return exchangeFailure(); + } finally { + reader.releaseLock(); + } + if (size === 0) return exchangeFailure(); + const body = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +} + +function boundedSafeString(value: unknown, minimum: number, maximum: number): string { + if (typeof value !== 'string' + || hasUnpairedSurrogate(value) + || CONTROL_CHARACTER.test(value)) return exchangeFailure(); + const bytes = UTF8.encode(value).byteLength; + if (bytes < minimum || bytes > maximum) return exchangeFailure(); + return value; +} + +function canonicalRelayUrl(value: unknown): string { + const relayUrl = boundedSafeString(value, 1, 2_048); + let parsed: URL; + try { + parsed = new URL(relayUrl); + } catch { + return exchangeFailure(); + } + if (parsed.protocol !== 'wss:' + || parsed.username !== '' + || parsed.password !== '' + || parsed.search !== '' + || parsed.hash !== '' + || !parsed.pathname.endsWith('/ws') + || parsed.href !== relayUrl) return exchangeFailure(); + return relayUrl; +} + +function accessTokenResponse(value: JsonValue, keyId: string, now: number): AccessToken { + const response = exactRecord(value, [ + 'schema', 'access_token', 'token_type', 'expires_at_ms', 'relay_url', 'key', + ], []); + const key = exactRecord(response.key, ['id', 'label'], []); + const accessToken = boundedSafeString(response.access_token, 1, MAXIMUM_ACCESS_TOKEN_BYTES); + if (response.schema !== 'miakapp.access-token/1' + || response.token_type !== 'Bearer' + || accessToken.split('.').length !== 3 + || !accessToken.split('.').every((segment) => BASE64URL.test(segment)) + || key.id !== keyId) return exchangeFailure(); + boundedSafeString(key.label, 1, 64); + const expiresAtMs = response.expires_at_ms; + if (typeof expiresAtMs !== 'number' + || !Number.isSafeInteger(expiresAtMs) + || expiresAtMs <= now + || expiresAtMs > now + MAXIMUM_ACCESS_TOKEN_LIFETIME_MS) return exchangeFailure(); + return Object.freeze({ + relayUrl: canonicalRelayUrl(response.relay_url), + token: accessToken, + expiresAtMs, + }); +} + +function validateTokenRequest(value: AccessTokenRequest): void { + const request = exactRecord( + value, + ['coordinatorName', 'reason', 'signal'], + ['relayHost'], + ); + if (typeof request.coordinatorName !== 'string' + || !COORDINATOR_NAME.test(request.coordinatorName) + || (request.reason !== 'initial' && request.reason !== 'reauth' && request.reason !== 'reconnect') + || request.signal === null + || typeof request.signal !== 'object' + || !('aborted' in request.signal) + || !('addEventListener' in request.signal) + || (request.relayHost !== undefined + && (typeof request.relayHost !== 'string' || request.relayHost.length === 0 || request.relayHost.length > 255))) { + return exchangeFailure(); + } +} + +// createHomeKeyAccessTokenProvider maps the RFC 0004 Home Key exchange onto +// MiakAPI's three-field access-token boundary. It performs exactly one request +// per SDK demand and never exposes the Home Key to the WebSocket layer. +export function createHomeKeyAccessTokenProvider( + value: HomeKeyAccessTokenProviderOptions, +): AccessTokenProvider { + const options = exactRecord(value, ['exchangeEndpoint', 'homeKey'], ['fetch']); + const endpoint = canonicalExchangeEndpoint(options.exchangeEndpoint); + const homeKey = validHomeKey(options.homeKey); + const fetcher = options.fetch ?? globalThis.fetch; + if (typeof fetcher !== 'function') return exchangeFailure(); + + return Object.freeze({ + async getAccessToken(request: AccessTokenRequest): Promise { + validateTokenRequest(request); + let response: Response; + try { + response = await fetcher(endpoint, { + method: 'POST', + headers: { + accept: 'application/json', + authorization: `Bearer ${homeKey.value}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + purpose: 'relay', + role: 'coordinator', + coordinator_name: request.coordinatorName, + reason: request.reason, + }), + cache: 'no-store', + credentials: 'omit', + redirect: 'error', + referrerPolicy: 'no-referrer', + signal: request.signal, + }); + } catch { + if (request.signal.aborted) throw request.signal.reason; + return exchangeFailure(); + } + try { + if (request.signal.aborted) { + await response.body?.cancel().catch(() => undefined); + throw request.signal.reason; + } + if (response.status !== 200) { + await response.body?.cancel().catch(() => undefined); + return exchangeFailure(); + } + if (response.headers.get('cache-control') !== 'no-store' + || response.headers.get('pragma') !== 'no-cache' + || response.headers.get('referrer-policy') !== 'no-referrer' + || response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json') { + await response.body?.cancel().catch(() => undefined); + return exchangeFailure(); + } + const body = await boundedResponseBody(response); + if (request.signal.aborted) throw request.signal.reason; + return accessTokenResponse(parseResponseJson(body), homeKey.keyId, Date.now()); + } catch { + if (request.signal.aborted) throw request.signal.reason; + return exchangeFailure(); + } + }, + }); +} diff --git a/src/index.ts b/src/index.ts index 1560f98..462c083 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ export * from './api.js'; +export * from './access-token-provider.js'; export { createCoordinator } from './coordinator.js'; diff --git a/test/access-token-provider.test.ts b/test/access-token-provider.test.ts new file mode 100644 index 0000000..4704f13 --- /dev/null +++ b/test/access-token-provider.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, test } from 'bun:test'; +import { + createHomeKeyAccessTokenProvider, + type HomeKeyAccessTokenProviderOptions, +} from '../src/access-token-provider.js'; +import type { AccessTokenRequest } from '../src/api.js'; +import { createCoordinatorWithRuntime } from '../src/coordinator.js'; +import { Opcode } from '../src/protocol/codec.js'; +import { FakeRelay } from './fakes/relay.js'; +import { FakeRuntime, flushMicrotasks } from './fakes/runtime.js'; +import { configuration } from './helpers.js'; + +const KEY_ID = 'AAAAAAAAAAAAAAAAAAAAAA'; +const HOME_KEY = `mhk1_${KEY_ID}_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA`; +const ENDPOINT = 'https://control.example.test/v1/access-tokens:exchange'; + +function request(reason: AccessTokenRequest['reason'] = 'initial'): AccessTokenRequest { + return { + coordinatorName: 'automation', + reason, + signal: new AbortController().signal, + }; +} + +function successHeaders(): Record { + return { + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=utf-8', + pragma: 'no-cache', + 'referrer-policy': 'no-referrer', + }; +} + +function successBody(overrides: Record = {}): Record { + return { + schema: 'miakapp.access-token/1', + access_token: 'header.payload.signature', + token_type: 'Bearer', + expires_at_ms: Date.now() + 300_000, + relay_url: 'wss://relay.example.test/miakapp/ws', + key: { id: KEY_ID, label: 'Synthetic coordinator' }, + ...overrides, + }; +} + +function response(body: string, init: ResponseInit = {}): Response { + return new Response(body, { + status: 200, + headers: successHeaders(), + ...init, + }); +} + +describe('Home Key access-token provider', () => { + test('performs one closed exchange and returns only the SDK token boundary', async () => { + const calls: Array<{ input: string; init: RequestInit }> = []; + const provider = createHomeKeyAccessTokenProvider({ + exchangeEndpoint: ENDPOINT, + homeKey: HOME_KEY, + async fetch(input, init) { + calls.push({ input, init }); + return response(JSON.stringify(successBody())); + }, + }); + const result = await provider.getAccessToken(request('reauth')); + expect(result).toEqual({ + relayUrl: 'wss://relay.example.test/miakapp/ws', + token: 'header.payload.signature', + expiresAtMs: expect.any(Number), + }); + expect(Object.isFrozen(result)).toBe(true); + expect(calls).toHaveLength(1); + const call = calls[0]; + if (call === undefined) throw new Error('missing exchange request'); + expect(call.input).toBe(ENDPOINT); + expect(call.init.method).toBe('POST'); + const fetchInit = call.init as RequestInit & Record; + expect(fetchInit.cache).toBe('no-store'); + expect(fetchInit.credentials).toBe('omit'); + expect(fetchInit.redirect).toBe('error'); + expect(fetchInit.referrerPolicy).toBe('no-referrer'); + expect(new Headers(call.init.headers)).toEqual(new Headers({ + accept: 'application/json', + authorization: `Bearer ${HOME_KEY}`, + 'content-type': 'application/json', + })); + expect(JSON.parse(call.init.body as string)).toEqual({ + purpose: 'relay', + role: 'coordinator', + coordinator_name: 'automation', + reason: 'reauth', + }); + }); + + test('feeds initial and scheduled REAUTH demands without a second connection loop', async () => { + const now = Date.now(); + const reasons: string[] = []; + const provider = createHomeKeyAccessTokenProvider({ + exchangeEndpoint: ENDPOINT, + homeKey: HOME_KEY, + async fetch(_input, init) { + const body = JSON.parse(init.body as string) as { reason: string }; + reasons.push(body.reason); + return response(JSON.stringify(successBody({ + access_token: `header.${body.reason}.signature`, + expires_at_ms: now + 60_000, + }))); + }, + }); + const relay = new FakeRelay({ expiresAtMs: now + 1_000_000, coordinatorName: 'automation' }); + const runtime = new FakeRuntime(relay, now); + const coordinator = createCoordinatorWithRuntime({ + name: 'automation', + accessTokenProvider: provider, + }, runtime); + coordinator.configure(configuration()); + const started = coordinator.start(); + const connection = await relay.connectionAt(0); + const hello = await connection.nextClientFrame(Opcode.Hello); + expect(hello.payload[4]).toBe('header.initial.signature'); + await connection.acknowledgeDeclarations(); + await started; + + await runtime.advanceBy(30_000); + const reauth = await connection.nextClientFrame(Opcode.Reauth); + expect(reauth.payload[1]).toBe('header.reauth.signature'); + connection.send({ + opcode: Opcode.ReauthOk, + payload: [reauth.payload[0] ?? 1, now + 60_000], + }); + await flushMicrotasks(); + + expect(reasons).toEqual(['initial', 'reauth']); + expect(relay.connections).toHaveLength(1); + expect(relay.socketHighWater).toBe(1); + await coordinator.stop(); + }); + + test('is inert at construction and performs no hidden retry', async () => { + let calls = 0; + const provider = createHomeKeyAccessTokenProvider({ + exchangeEndpoint: ENDPOINT, + homeKey: HOME_KEY, + async fetch() { + calls += 1; + return new Response('unavailable', { status: 503 }); + }, + }); + expect(calls).toBe(0); + await expect(provider.getAccessToken(request())).rejects.toThrow('Miakapp access-token exchange failed'); + expect(calls).toBe(1); + }); + + test('propagates SDK cancellation without retaining the Home Key in an error', async () => { + const controller = new AbortController(); + const cancellation = new Error('synthetic cancellation'); + const provider = createHomeKeyAccessTokenProvider({ + exchangeEndpoint: ENDPOINT, + homeKey: HOME_KEY, + fetch: async (_input, init) => new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => reject(new Error(HOME_KEY)), { once: true }); + }), + }); + const pending = provider.getAccessToken({ + coordinatorName: 'automation', + reason: 'initial', + signal: controller.signal, + }); + controller.abort(cancellation); + await expect(pending).rejects.toBe(cancellation); + }); + + test('rejects malformed configuration before network access', () => { + const cases: HomeKeyAccessTokenProviderOptions[] = [ + { exchangeEndpoint: 'http://control.example.test/v1/access-tokens:exchange', homeKey: HOME_KEY }, + { exchangeEndpoint: `${ENDPOINT}?redirect=true`, homeKey: HOME_KEY }, + { exchangeEndpoint: 'https://user@control.example.test/v1/access-tokens:exchange', homeKey: HOME_KEY }, + { exchangeEndpoint: ENDPOINT, homeKey: 'mhk1_invalid' }, + { exchangeEndpoint: ENDPOINT, homeKey: `${HOME_KEY}=` }, + ]; + for (const options of cases) { + expect(() => createHomeKeyAccessTokenProvider(options)).toThrow('Miakapp access-token exchange failed'); + } + expect(() => createHomeKeyAccessTokenProvider({ + exchangeEndpoint: ENDPOINT, + homeKey: HOME_KEY, + unknown: true, + } as HomeKeyAccessTokenProviderOptions)).toThrow('Miakapp access-token exchange failed'); + }); + + test('rejects open, duplicate, unsafe, stale, or mismatched response shapes', async () => { + const valid = successBody(); + const malformedBodies: string[] = [ + JSON.stringify({ ...valid, unknown: true }), + JSON.stringify({ ...valid, schema: 'other' }), + JSON.stringify({ ...valid, access_token: 'not-a-compact-token' }), + JSON.stringify({ ...valid, expires_at_ms: Date.now() }), + JSON.stringify({ ...valid, expires_at_ms: Date.now() + 331_000 }), + JSON.stringify({ ...valid, relay_url: 'ws://relay.example.test/ws' }), + JSON.stringify({ ...valid, key: { id: 'AQEBAQEBAQEBAQEBAQEBAQ', label: 'Other' } }), + JSON.stringify({ ...valid, key: { id: KEY_ID, label: 'line\nbreak' } }), + `{"schema":"miakapp.access-token/1","schema":"miakapp.access-token/1","access_token":"header.payload.signature","token_type":"Bearer","expires_at_ms":${Date.now() + 300_000},"relay_url":"wss://relay.example.test/ws","key":{"id":"${KEY_ID}","label":"Synthetic"}}`, + `{"schema":"miakapp.access-token/1","access_token":"header.payload.signature","token_type":"Bearer","expires_at_ms":${Date.now() + 300_000},"relay_url":"wss://relay.example.test/ws","key":{"id":"${KEY_ID}","label":"\\ud800"}}`, + 'x'.repeat(65_537), + ]; + for (const body of malformedBodies) { + const provider = createHomeKeyAccessTokenProvider({ + exchangeEndpoint: ENDPOINT, + homeKey: HOME_KEY, + fetch: async () => response(body), + }); + await expect(provider.getAccessToken(request())).rejects.toThrow('Miakapp access-token exchange failed'); + } + }); + + test('requires the closed no-store response headers', async () => { + const headerCases: Array> = [ + { 'content-type': 'application/json', pragma: 'no-cache', 'referrer-policy': 'no-referrer' }, + { 'cache-control': 'no-store', 'content-type': 'text/plain', pragma: 'no-cache', 'referrer-policy': 'no-referrer' }, + { 'cache-control': 'no-store', 'content-type': 'application/json', 'referrer-policy': 'no-referrer' }, + { 'cache-control': 'no-store', 'content-type': 'application/json', pragma: 'no-cache' }, + ]; + for (const headers of headerCases) { + const provider = createHomeKeyAccessTokenProvider({ + exchangeEndpoint: ENDPOINT, + homeKey: HOME_KEY, + fetch: async () => new Response(JSON.stringify(successBody()), { status: 200, headers }), + }); + await expect(provider.getAccessToken(request())).rejects.toThrow('Miakapp access-token exchange failed'); + } + }); + + test('keeps Home Key material out of every public failure', async () => { + const networkProvider = createHomeKeyAccessTokenProvider({ + exchangeEndpoint: ENDPOINT, + homeKey: HOME_KEY, + fetch: async () => { throw new Error(`failed with ${HOME_KEY}`); }, + }); + const responseProvider = createHomeKeyAccessTokenProvider({ + exchangeEndpoint: ENDPOINT, + homeKey: HOME_KEY, + fetch: async () => Object.defineProperty({}, 'status', { + get: () => { throw new Error(`failed with ${HOME_KEY}`); }, + }) as Response, + }); + for (const provider of [networkProvider, responseProvider]) { + const failure = await provider.getAccessToken(request()).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(Error); + expect(String(failure)).not.toContain(HOME_KEY); + } + }); +}); diff --git a/test/node-smoke.mjs b/test/node-smoke.mjs index 60a1ade..0f1c2a5 100644 --- a/test/node-smoke.mjs +++ b/test/node-smoke.mjs @@ -3,9 +3,11 @@ import { ApplicationCallError, EventDirection, createCoordinator, + createHomeKeyAccessTokenProvider, } from '../dist/index.js'; assert.equal(typeof createCoordinator, 'function'); +assert.equal(typeof createHomeKeyAccessTokenProvider, 'function'); assert.equal(EventDirection.publishToUsers, 0x02); assert.equal(new ApplicationCallError(2000, 'Expected').code, 2000); diff --git a/test/public-api.test.ts b/test/public-api.test.ts index 3513900..51538e3 100644 --- a/test/public-api.test.ts +++ b/test/public-api.test.ts @@ -17,6 +17,7 @@ import { configuration, createTestHarness, isCoordinatorFailure } from './helper describe('public API', () => { test('exports the canonical surface and the coordinator factory', () => { expect(typeof entrypoint.createCoordinator).toBe('function'); + expect(typeof entrypoint.createHomeKeyAccessTokenProvider).toBe('function'); expect(entrypoint.ApplicationCallError).toBe(ApplicationCallError); expect(entrypoint.EventDirection).toBe(EventDirection); });