From abd53d36c43d368b2fed05e0ff98e3e2f9b227f8 Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Fri, 4 Sep 2026 20:44:50 +0200 Subject: [PATCH] feat: add audience-bound browser credentials --- README.md | 67 ++-- src/access-token-provider.ts | 241 +----------- src/browser-api.ts | 28 +- src/browser-client.ts | 116 ++++-- src/browser-relay-credential-provider.ts | 290 ++++++++++++++ src/browser.ts | 1 + src/internal/control-plane-response.ts | 273 +++++++++++++ src/internal/validation.ts | 52 ++- test/browser-lifecycle.test.ts | 203 ++++++++-- test/browser-public-api.test.ts | 32 +- .../browser-relay-credential-provider.test.ts | 372 ++++++++++++++++++ test/fakes/relay.ts | 8 +- test/fakes/user-relay.ts | 21 +- test/integration/browser.ts | 25 +- test/node-smoke.mjs | 7 +- test/type-contract.ts | 11 +- 16 files changed, 1374 insertions(+), 373 deletions(-) create mode 100644 src/browser-relay-credential-provider.ts create mode 100644 src/internal/control-plane-response.ts create mode 100644 test/browser-relay-credential-provider.test.ts diff --git a/README.md b/README.md index 06fd514..6450169 100644 --- a/README.md +++ b/README.md @@ -157,19 +157,28 @@ application. It relies on the browser's native WebSocket implementation and does not expose coordinator declarations, Home Keys, or the Node.js `ws` transport. ```ts -import { createBrowserClient } from 'miakapi/browser'; +import { + createBrowserClient, + createControlPlaneBrowserRelayCredentialProvider, +} from 'miakapi/browser'; + +const credentialProvider = createControlPlaneBrowserRelayCredentialProvider({ + exchangeEndpoint: 'https://control.example.com/v1/user-relay-tokens:exchange', + async getFirebaseIdToken({ signal }) { + if (signal.aborted) throw signal.reason; + const user = firebaseAuth.currentUser; + if (user === null) throw new Error('The user is signed out'); + return user.getIdToken(); + }, + async getAppCheckToken({ signal }) { + if (signal.aborted) throw signal.reason; + return (await getToken(firebaseAppCheck)).token; + }, +}); const client = createBrowserClient({ homeId: 'my-home', - relayUrl: 'wss://relay.example.com/miakapp/ws', - idTokenProvider: { - async getIdToken({ signal }) { - if (signal.aborted) throw signal.reason; - const user = firebaseAuth.currentUser; - if (user === null) throw new Error('The user is signed out'); - return user.getIdToken(); - }, - }, + credentialProvider, }); await client.start(); @@ -196,22 +205,28 @@ removeHomeListener(); await client.stop(); ``` -`idTokenProvider` is invoked for the initial connection, same-socket -reauthentication, and reconnects. Return a fresh Firebase ID token from trusted -in-memory application state. Never place the token in the relay URL, a WebSocket -subprotocol, persistent browser storage, logs, or error messages. MiakAPI sends -it only inside the authenticated binary protocol handshake or `REAUTH` frame. -Stop and discard the client immediately when the Firebase user signs out or the -selected home or relay changes; create a new client for the new identity tuple. - -The configured relay receives that Firebase ID token as a bearer credential and -can observe the home data flowing through it. Until the control plane issues a -short-lived credential scoped to one relay, home, and user role, use this client -only with an official relay or one the user explicitly trusts as completely as -the Miakapp backend. An arbitrary community relay catalogue is not a safe -production use of this authentication profile. The first browser integration -fixture uses synthetic credentials only; Miakapp application wiring remains -blocked on this trust decision. +This replaces the earlier alpha browser options `relayUrl` and +`idTokenProvider`. They are intentionally rejected: callers must not pair an +independently selected relay with a source or access token. + +The credential provider is invoked for the initial connection, reauthentication, +and reconnects. Its Firebase ID and App Check callbacks run only inside the +trusted host and send those source tokens solely to the HTTPS control plane. +MiakAPI never places them in a relay URL, WebSocket subprotocol, persistent +browser storage, log, error, `HELLO`, or `REAUTH` frame. + +The control plane returns an up-to-five-minute Miakapp access token atomically +with its authoritative relay URL. MiakAPI sends only that audience-bound token +to the returned relay. If a renewal selects a different relay, the client closes +the old session and opens the replacement with the already-issued credential; it +does not expose the new token to the old relay or repeat the exchange. Stop and +discard the client immediately when the Firebase user signs out or the selected +home changes. Relay routing changes arrive through credentials and do not require +mutating the client options. + +Audience binding limits credential replay; it does not encrypt home traffic from +the selected relay. Users should still choose an operator they trust with the +plaintext state and calls that transit through it. Browser state snapshots are defensive copies and become `stale` immediately when continuity is lost. Revision or dictionary mismatches trigger one diff --git a/src/access-token-provider.ts b/src/access-token-provider.ts index cdc7d13..9e74fdc 100644 --- a/src/access-token-provider.ts +++ b/src/access-token-provider.ts @@ -3,22 +3,20 @@ import type { AccessTokenProvider, AccessTokenRequest, } from './api.js'; +import { + boundedResponseBody, + boundedResponseString, + cancelResponseBody, + canonicalRelayUrl, + parseResponseJson, + type JsonValue, +} from './internal/control-plane-response.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; @@ -80,229 +78,18 @@ function validHomeKey(value: unknown): { value: string; keyId: string } { 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); + const accessToken = boundedResponseString(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); + boundedResponseString(key.label, 1, 64); const expiresAtMs = response.expires_at_ms; if (typeof expiresAtMs !== 'number' || !Number.isSafeInteger(expiresAtMs) @@ -376,21 +163,21 @@ export function createHomeKeyAccessTokenProvider( } try { if (request.signal.aborted) { - await response.body?.cancel().catch(() => undefined); + cancelResponseBody(response); throw request.signal.reason; } if (response.status !== 200) { - await response.body?.cancel().catch(() => undefined); + cancelResponseBody(response); 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); + cancelResponseBody(response); return exchangeFailure(); } - const body = await boundedResponseBody(response); + const body = await boundedResponseBody(response, request.signal); if (request.signal.aborted) throw request.signal.reason; return accessTokenResponse(parseResponseJson(body), homeKey.keyId, Date.now()); } catch { diff --git a/src/browser-api.ts b/src/browser-api.ts index 4406598..a123134 100644 --- a/src/browser-api.ts +++ b/src/browser-api.ts @@ -17,16 +17,31 @@ export type BrowserClientStatus = | 'stopping' | 'stopped'; -export type FirebaseIdTokenReason = 'initial' | 'reauth' | 'reconnect'; +export type BrowserRelayCredentialReason = 'initial' | 'reauth' | 'reconnect'; -export interface FirebaseIdTokenRequest { +export interface BrowserRelayCredentialRequest { readonly homeId: string; - readonly reason: FirebaseIdTokenReason; + readonly reason: BrowserRelayCredentialReason; readonly signal: AbortSignal; } -export interface FirebaseIdTokenProvider { - getIdToken(request: FirebaseIdTokenRequest): Promise; +export interface BrowserRelayCredential { + readonly relayUrl: string; + readonly accessToken: string; + readonly expiresAtMs: number; +} + +export interface BrowserRelayCredentialProvider { + getCredential(request: BrowserRelayCredentialRequest): Promise; +} + +export interface ControlPlaneBrowserRelayCredentialProviderOptions { + readonly exchangeEndpoint: string; + readonly getFirebaseIdToken: + (request: BrowserRelayCredentialRequest) => Promise; + readonly getAppCheckToken: + (request: BrowserRelayCredentialRequest) => Promise; + readonly fetch?: typeof globalThis.fetch; } export interface BrowserClientLogRecord { @@ -42,8 +57,7 @@ export interface BrowserClientLogger { export interface BrowserClientOptions { readonly homeId: string; - readonly relayUrl: string; - readonly idTokenProvider: FirebaseIdTokenProvider; + readonly credentialProvider: BrowserRelayCredentialProvider; readonly logger?: BrowserClientLogger; } diff --git a/src/browser-client.ts b/src/browser-client.ts index 31e4187..ae15eb6 100644 --- a/src/browser-client.ts +++ b/src/browser-client.ts @@ -13,8 +13,9 @@ import type { BrowserHomeStatus, BrowserLifecycleEvent, BrowserReadySession, - FirebaseIdTokenReason, - FirebaseIdTokenRequest, + BrowserRelayCredential, + BrowserRelayCredentialReason, + BrowserRelayCredentialRequest, } from './browser-api.js'; import { createBrowserRuntime } from './internal/browser-socket.js'; import { @@ -40,7 +41,7 @@ import { parseUserHomeStatus, UserRelaySession } from './internal/user-session.j import { UserStateManager, type UserStateHost } from './internal/user-state.js'; import { validateBrowserClientOptions, - validateFirebaseIdToken, + validateBrowserRelayCredential, validateStartOptions, validateStopOptions, } from './internal/validation.js'; @@ -49,16 +50,18 @@ import { Opcode, type Frame, type ProtocolValue } from './protocol/codec.js'; interface SessionEnd { readonly failure?: BrowserClientFailure; readonly retryAfterMs?: number; + readonly handoffCredential?: BrowserRelayCredential; } -interface TokenRequest { +interface CredentialRequest { readonly controller: AbortController; readonly dispose: Unsubscribe; - readonly promise: Promise; + readonly promise: Promise; } interface PendingReauthentication { readonly requestId: number; + readonly maximumExpiresAtMs: number; readonly deferred: Deferred; readonly timer: RuntimeTimer; } @@ -110,9 +113,10 @@ class BrowserClientImpl implements BrowserClient, UserStateHost, UserCallHost { #loopTask: Promise | undefined; #startSignal: AbortSignal | undefined; #startAbort: (() => void) | undefined; - #tokenRequest: TokenRequest | undefined; + #credentialRequest: CredentialRequest | undefined; #reauthentication: PendingReauthentication | undefined; #reauthTimer: RuntimeTimer | undefined; + #sessionRelayUrl: string | undefined; #requestIds = new IdSequence(); #callIds = new IdSequence(); readonly #localCallIds = new IdSequence(); @@ -183,7 +187,7 @@ class BrowserClientImpl implements BrowserClient, UserStateHost, UserCallHost { this.state.stop(); this.#markHomeStale(); this.#clearReauthentication(); - this.#abortTokenRequest(); + this.#abortCredentialRequest(); this.#loopController.abort(stopping); this.#sessionReady?.reject(stopping); this.#sessionEnd?.resolve({ failure: stopping }); @@ -255,15 +259,20 @@ class BrowserClientImpl implements BrowserClient, UserStateHost, UserCallHost { } async #runConnectionLoop(): Promise { - let reason: FirebaseIdTokenReason = 'initial'; + let reason: BrowserRelayCredentialReason = 'initial'; + let pendingCredential: BrowserRelayCredential | undefined; while (!this.#loopController.signal.aborted) { let end: SessionEnd = {}; let connectionEnd: Deferred | undefined; try { this.#transition('connecting'); if (this.#loopController.signal.aborted) break; - const token = await this.#getIdToken(reason); + const credential = pendingCredential ?? await this.#getCredential(reason); + pendingCredential = undefined; if (this.#loopController.signal.aborted) break; + if (credential.expiresAtMs <= this.#runtime.now()) { + throw browserUnavailable('Browser relay credential expired before connection'); + } this.#transition('authenticating'); if (this.#loopController.signal.aborted) break; this.#requestIds = new IdSequence(); @@ -277,8 +286,8 @@ class BrowserClientImpl implements BrowserClient, UserStateHost, UserCallHost { const session = await UserRelaySession.connect( this.#runtime, this.#options.homeId, - this.#options.relayUrl, - token, + credential.relayUrl, + credential.accessToken, this.#loopController.signal, { frame: (frame) => this.#handleFrame(frame), @@ -298,13 +307,19 @@ class BrowserClientImpl implements BrowserClient, UserStateHost, UserCallHost { }, }, ); - this.#reconnectAttempt = 0; if (this.#loopController.signal.aborted) { session.terminate(); session.detach(); break; } + if (credential.expiresAtMs <= this.#runtime.now()) { + session.terminate(); + session.detach(); + throw browserUnavailable('Browser relay credential expired during authentication'); + } + this.#reconnectAttempt = 0; this.#session = session; + this.#sessionRelayUrl = credential.relayUrl; this.#setHomeStatus(Object.freeze({ enrolled: session.welcome.readySession.enrolled, coordinators: session.welcome.readySession.coordinators, @@ -318,7 +333,10 @@ class BrowserClientImpl implements BrowserClient, UserStateHost, UserCallHost { this.calls.beginSession(session.welcome.epoch); this.#transition('synchronizing'); if (this.#loopController.signal.aborted) break; - this.#scheduleReauthentication(session.welcome.expiresAtMs); + this.#scheduleReauthentication(Math.min( + session.welcome.expiresAtMs, + credential.expiresAtMs, + )); const bootstrapTimeout = this.#runtime.setTimer(() => { const failure = browserUnavailable('Browser relay bootstrap timed out'); sessionReady.reject(failure); @@ -354,6 +372,11 @@ class BrowserClientImpl implements BrowserClient, UserStateHost, UserCallHost { if (this.#loopController.signal.aborted) break; this.#transition('reconnecting', undefined, end.failure); if (this.#loopController.signal.aborted) break; + if (end.handoffCredential !== undefined) { + pendingCredential = end.handoffCredential; + reason = 'reconnect'; + continue; + } const ceiling = Math.min( FIRST_RECONNECT_CEILING_MS * (2 ** this.#reconnectAttempt), MAX_RECONNECT_CEILING_MS, @@ -373,19 +396,19 @@ class BrowserClientImpl implements BrowserClient, UserStateHost, UserCallHost { } } - async #getIdToken( - reason: FirebaseIdTokenReason, + async #getCredential( + reason: BrowserRelayCredentialReason, timeoutMs = SESSION_PHASE_TIMEOUT_MS, - ): Promise { - if (this.#tokenRequest !== undefined) return this.#tokenRequest.promise; + ): Promise { + if (this.#credentialRequest !== undefined) return this.#credentialRequest.promise; const child = childAbortController(this.#loopController.signal); - const request: FirebaseIdTokenRequest = Object.freeze({ + const request: BrowserRelayCredentialRequest = Object.freeze({ homeId: this.#options.homeId, reason, signal: child.controller.signal, }); let abort: (() => void) | undefined; - const interrupted = new Promise((_resolve, reject) => { + const interrupted = new Promise((_resolve, reject) => { abort = () => reject(child.controller.signal.reason ?? browserCancelled('not_dispatched')); if (child.controller.signal.aborted) abort(); else child.controller.signal.addEventListener('abort', abort, { once: true }); @@ -395,12 +418,12 @@ class BrowserClientImpl implements BrowserClient, UserStateHost, UserCallHost { if (child.controller.signal.aborted) { throw child.controller.signal.reason ?? browserCancelled('not_dispatched'); } - return this.#options.idTokenProvider.getIdToken(request); + return this.#options.credentialProvider.getCredential(request); }) - .then((value) => validateFirebaseIdToken(value)) - .catch(() => { throw browserUnavailable('Firebase ID token provider failed'); }); + .then((value) => validateBrowserRelayCredential(value, this.#runtime.now())) + .catch(() => { throw browserUnavailable('Browser relay credential provider failed'); }); const timeout = this.#runtime.setTimer(() => { - child.controller.abort(browserUnavailable('Firebase ID token request timed out')); + child.controller.abort(browserUnavailable('Browser relay credential request timed out')); }, Math.max(1, Math.min(timeoutMs, SESSION_PHASE_TIMEOUT_MS))); const promise = Promise.race([provider, interrupted]); let disposed = false; @@ -411,21 +434,21 @@ class BrowserClientImpl implements BrowserClient, UserStateHost, UserCallHost { if (abort !== undefined) child.controller.signal.removeEventListener('abort', abort); child.dispose(); }; - const tokenRequest = { controller: child.controller, dispose, promise }; - this.#tokenRequest = tokenRequest; + const credentialRequest = { controller: child.controller, dispose, promise }; + this.#credentialRequest = credentialRequest; void promise.finally(() => { dispose(); - if (this.#tokenRequest === tokenRequest) this.#tokenRequest = undefined; + if (this.#credentialRequest === credentialRequest) this.#credentialRequest = undefined; }).catch(() => undefined); return promise; } - #abortTokenRequest(): void { - const request = this.#tokenRequest; + #abortCredentialRequest(): void { + const request = this.#credentialRequest; if (request === undefined) return; request.controller.abort(browserCancelled('not_dispatched')); request.dispose(); - this.#tokenRequest = undefined; + this.#credentialRequest = undefined; } #scheduleReauthentication(expiresAtMs: number): void { @@ -443,12 +466,24 @@ class BrowserClientImpl implements BrowserClient, UserStateHost, UserCallHost { const session = this.#session; if (session === undefined || this.#loopController.signal.aborted) return; try { - const token = await this.#getIdToken( + const credential = await this.#getCredential( 'reauth', Math.max(1, currentExpiresAtMs - this.#runtime.now()), ); if (!this.#mayReauthenticate(session)) return; - const remaining = currentExpiresAtMs - this.#runtime.now(); + if (this.#sessionRelayUrl === undefined) { + throw browserUnavailable('Browser relay routing state is unavailable'); + } + if (credential.relayUrl !== this.#sessionRelayUrl) { + if (this.#sessionEnd === undefined || this.#sessionEnd.settled) { + throw browserUnavailable('Browser relay handoff state is unavailable'); + } + this.#sessionEnd.resolve({ handoffCredential: credential }); + session.terminate(); + return; + } + const remaining = Math.min(currentExpiresAtMs, credential.expiresAtMs) + - this.#runtime.now(); if (remaining <= 0) throw browserUnavailable('Browser authentication lease expired'); const requestId = this.nextRequestId(); const deferred = createDeferred(); @@ -456,8 +491,13 @@ class BrowserClientImpl implements BrowserClient, UserStateHost, UserCallHost { const timer = this.#runtime.setTimer(() => { deferred.reject(browserUnavailable('Browser reauthentication timed out')); }, Math.max(1, Math.min(remaining, SESSION_PHASE_TIMEOUT_MS))); - this.#reauthentication = { requestId, deferred, timer }; - await session.send({ opcode: Opcode.Reauth, payload: [requestId, token] }); + this.#reauthentication = { + requestId, + maximumExpiresAtMs: credential.expiresAtMs, + deferred, + timer, + }; + await session.send({ opcode: Opcode.Reauth, payload: [requestId, credential.accessToken] }); const expiresAtMs = await deferred.promise; if (this.#mayReauthenticate(session)) this.#scheduleReauthentication(expiresAtMs); } catch { @@ -506,8 +546,9 @@ class BrowserClientImpl implements BrowserClient, UserStateHost, UserCallHost { throw browserProtocolFailure('REAUTH_OK is not correlated'); } const expiresAtMs = relayInteger(frame, 1, 'REAUTH_OK.expiresAtMs', 1); - if (expiresAtMs <= this.#runtime.now()) { - throw browserProtocolFailure('REAUTH_OK expiry is not in the future'); + if (expiresAtMs <= this.#runtime.now() + || expiresAtMs > this.#reauthentication.maximumExpiresAtMs) { + throw browserProtocolFailure('REAUTH_OK expiry is outside the credential lease'); } this.#reauthentication.timer.cancel(); this.#reauthentication.deferred.resolve(expiresAtMs); @@ -518,7 +559,7 @@ class BrowserClientImpl implements BrowserClient, UserStateHost, UserCallHost { this.#transition('draining'); this.#reauthTimer?.cancel(); this.#reauthTimer = undefined; - this.#abortTokenRequest(); + this.#abortCredentialRequest(); this.#goawayRetryAfterMs = relayInteger(frame, 0, 'GOAWAY.retryAfterMs'); return; } @@ -587,12 +628,13 @@ class BrowserClientImpl implements BrowserClient, UserStateHost, UserCallHost { this.#bootstrapTimer?.cancel(); this.#bootstrapTimer = undefined; this.#clearReauthentication(); - this.#abortTokenRequest(); + this.#abortCredentialRequest(); this.state.disconnected(); this.calls.disconnected(); this.#markHomeStale(); this.#session?.detach(); this.#session = undefined; + this.#sessionRelayUrl = undefined; this.#sessionEnd = undefined; this.#sessionReady = undefined; this.#goawayRetryAfterMs = undefined; diff --git a/src/browser-relay-credential-provider.ts b/src/browser-relay-credential-provider.ts new file mode 100644 index 0000000..2d3490a --- /dev/null +++ b/src/browser-relay-credential-provider.ts @@ -0,0 +1,290 @@ +import type { + BrowserRelayCredential, + BrowserRelayCredentialProvider, + BrowserRelayCredentialRequest, + ControlPlaneBrowserRelayCredentialProviderOptions, +} from './browser-api.js'; +import { + boundedResponseBody, + boundedResponseString, + cancelResponseBody, + canonicalRelayUrl, + exactResponseRecord, + parseResponseJson, + type JsonValue, +} from './internal/control-plane-response.js'; + +const HOME_ID = /^[a-z][a-z0-9-]{1,61}[a-z0-9]$/; +const BASE64URL = /^[A-Za-z0-9_-]+$/; +const GRAPHIC_ASCII = /^[\x21-\x7e]+$/; +const MAXIMUM_SOURCE_TOKEN_BYTES = 8_192; +const MAXIMUM_ACCESS_TOKEN_BYTES = 8_192; +const MAXIMUM_ACCESS_TOKEN_LIFETIME_MS = 330_000; +const UTF8 = new TextEncoder(); + +function exchangeFailure(): never { + throw new Error('Miakapp browser relay credential exchange failed'); +} + +function plainExactRecord( + value: unknown, + required: readonly string[], + optional: readonly string[], +): Readonly> { + try { + if (value === null || Array.isArray(value) || typeof value !== 'object') { + return exchangeFailure(); + } + const prototype = Object.getPrototypeOf(value); + const keys = Object.keys(value); + const ownKeys = Reflect.ownKeys(value); + const descriptors = Object.getOwnPropertyDescriptors(value); + const allowed = new Set([...required, ...optional]); + if ((prototype !== Object.prototype && prototype !== null) + || ownKeys.length !== keys.length + || required.some((key) => !Object.hasOwn(descriptors, key)) + || keys.some((key) => { + const descriptor = descriptors[key]; + return !allowed.has(key) + || descriptor === undefined + || !descriptor.enumerable + || !Object.hasOwn(descriptor, 'value'); + })) return exchangeFailure(); + const result: Record = Object.create(null); + for (const key of keys) result[key] = descriptors[key]?.value; + return Object.freeze(result); + } catch { + return exchangeFailure(); + } +} + +function canonicalExchangeEndpoint(value: unknown): string { + if (typeof value !== 'string' + || value.length === 0 + || UTF8.encode(value).byteLength > 2_048) return exchangeFailure(); + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return exchangeFailure(); + } + if (parsed.protocol !== 'https:' + || parsed.hostname === '' + || parsed.username !== '' + || parsed.password !== '' + || parsed.search !== '' + || parsed.hash !== '' + || parsed.pathname !== '/v1/user-relay-tokens:exchange' + || parsed.href !== value) return exchangeFailure(); + return value; +} + +function validateRequest(value: BrowserRelayCredentialRequest): BrowserRelayCredentialRequest { + const request = plainExactRecord(value, ['homeId', 'reason', 'signal'], []); + if (typeof request.homeId !== 'string' + || !HOME_ID.test(request.homeId) + || (request.reason !== 'initial' + && request.reason !== 'reauth' + && request.reason !== 'reconnect') + || !(request.signal instanceof AbortSignal)) return exchangeFailure(); + return Object.freeze({ + homeId: request.homeId, + reason: request.reason, + signal: request.signal, + }); +} + +function sourceToken(value: unknown): string { + if (typeof value !== 'string' + || value.length === 0 + || value.length > MAXIMUM_SOURCE_TOKEN_BYTES + || !GRAPHIC_ASCII.test(value)) return exchangeFailure(); + const segments = value.split('.'); + if (segments.length !== 3 || segments.some((segment) => !BASE64URL.test(segment))) { + return exchangeFailure(); + } + return value; +} + +function credentialResponse( + value: JsonValue, + firebaseIdToken: string, + appCheckToken: string, + now: number, +): BrowserRelayCredential { + const response = exactResponseRecord(value, [ + 'schema', 'access_token', 'token_type', 'expires_at_ms', 'relay_url', + ], []); + const accessToken = boundedResponseString( + response.access_token, + 1, + MAXIMUM_ACCESS_TOKEN_BYTES, + ); + const segments = accessToken.split('.'); + const expiresAtMs = response.expires_at_ms; + if (response.schema !== 'miakapp.user-relay-token/1' + || response.token_type !== 'Bearer' + || segments.length !== 3 + || segments.some((segment) => !BASE64URL.test(segment)) + || accessToken === firebaseIdToken + || accessToken === appCheckToken + || 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), + accessToken, + expiresAtMs, + }); +} + +function abortReason(signal: AbortSignal): unknown { + if (signal.reason !== undefined) return signal.reason; + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + return error; +} + +function withAbort(operation: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(abortReason(signal)); + return new Promise((resolve, reject) => { + const aborted = () => { + signal.removeEventListener('abort', aborted); + reject(abortReason(signal)); + }; + signal.addEventListener('abort', aborted, { once: true }); + void operation.then( + (value) => { + signal.removeEventListener('abort', aborted); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener('abort', aborted); + reject(error); + }, + ); + }); +} + +async function exchangeCredential( + endpoint: string, + fetcher: (input: string, init: RequestInit) => Promise, + getFirebaseIdToken: (request: BrowserRelayCredentialRequest) => Promise, + getAppCheckToken: (request: BrowserRelayCredentialRequest) => Promise, + request: BrowserRelayCredentialRequest, +): Promise { + let response: Response | undefined; + try { + const firebaseIdToken = sourceToken(await withAbort( + Promise.resolve().then(() => getFirebaseIdToken(request)), + request.signal, + )); + const appCheckToken = sourceToken(await withAbort( + Promise.resolve().then(() => getAppCheckToken(request)), + request.signal, + )); + if (firebaseIdToken === appCheckToken) return exchangeFailure(); + response = await withAbort(fetcher(endpoint, { + method: 'POST', + headers: { + accept: 'application/json', + authorization: `Bearer ${firebaseIdToken}`, + 'content-type': 'application/json', + 'x-firebase-appcheck': appCheckToken, + }, + body: JSON.stringify({ home_id: request.homeId, reason: request.reason }), + cache: 'no-store', + credentials: 'omit', + redirect: 'error', + referrerPolicy: 'no-referrer', + signal: request.signal, + } as RequestInit), request.signal); + if (request.signal.aborted) throw abortReason(request.signal); + if (response.status !== 200 || response.redirected || response.type === 'opaqueredirect') { + cancelResponseBody(response); + return exchangeFailure(); + } + if (response.headers.get('cache-control') !== 'no-store' + || response.headers.get('pragma') !== 'no-cache' + || response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + !== 'application/json') { + cancelResponseBody(response); + return exchangeFailure(); + } + const body = await withAbort(boundedResponseBody(response, request.signal), request.signal); + if (request.signal.aborted) throw abortReason(request.signal); + return credentialResponse(parseResponseJson(body), firebaseIdToken, appCheckToken, Date.now()); + } catch { + if (request.signal.aborted) { + cancelResponseBody(response); + throw abortReason(request.signal); + } + return exchangeFailure(); + } +} + +export function createControlPlaneBrowserRelayCredentialProvider( + value: ControlPlaneBrowserRelayCredentialProviderOptions, +): BrowserRelayCredentialProvider { + let endpoint: string; + let getFirebaseIdToken: (request: BrowserRelayCredentialRequest) => Promise; + let getAppCheckToken: (request: BrowserRelayCredentialRequest) => Promise; + let fetcher: (input: string, init: RequestInit) => Promise; + try { + const options = plainExactRecord( + value, + ['exchangeEndpoint', 'getFirebaseIdToken', 'getAppCheckToken'], + ['fetch'], + ); + endpoint = canonicalExchangeEndpoint(options.exchangeEndpoint); + if (typeof options.getFirebaseIdToken !== 'function' + || typeof options.getAppCheckToken !== 'function' + || (options.fetch !== undefined && typeof options.fetch !== 'function')) { + return exchangeFailure(); + } + getFirebaseIdToken = options.getFirebaseIdToken as + (request: BrowserRelayCredentialRequest) => Promise; + getAppCheckToken = options.getAppCheckToken as + (request: BrowserRelayCredentialRequest) => Promise; + const selectedFetch = (options.fetch ?? globalThis.fetch) as + ((input: string, init: RequestInit) => Promise) | undefined; + if (typeof selectedFetch !== 'function') return exchangeFailure(); + fetcher = selectedFetch; + } catch { + return exchangeFailure(); + } + const inflight = new WeakMap>>(); + + return Object.freeze({ + getCredential(valueRequest: BrowserRelayCredentialRequest): Promise { + let request: BrowserRelayCredentialRequest; + try { + request = validateRequest(valueRequest); + } catch { + return Promise.reject(new Error('Miakapp browser relay credential exchange failed')); + } + if (request.signal.aborted) return Promise.reject(abortReason(request.signal)); + const key = `${request.homeId}\u0000${request.reason}`; + let requests = inflight.get(request.signal); + if (requests === undefined) { + requests = new Map(); + inflight.set(request.signal, requests); + } + const existing = requests.get(key); + if (existing !== undefined) return existing; + const pending = exchangeCredential( + endpoint, + fetcher, + getFirebaseIdToken, + getAppCheckToken, + request, + ); + requests.set(key, pending); + void pending.finally(() => { + if (requests?.get(key) === pending) requests.delete(key); + }).catch(() => undefined); + return pending; + }, + }); +} diff --git a/src/browser.ts b/src/browser.ts index 6f287bb..048f67b 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -1,3 +1,4 @@ export * from './browser-api.js'; export { createBrowserClient } from './browser-client.js'; +export { createControlPlaneBrowserRelayCredentialProvider } from './browser-relay-credential-provider.js'; diff --git a/src/internal/control-plane-response.ts b/src/internal/control-plane-response.ts new file mode 100644 index 0000000..c84f5b2 --- /dev/null +++ b/src/internal/control-plane-response.ts @@ -0,0 +1,273 @@ +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 CONTROL_CHARACTER = /\p{Cc}/u; +const UTF8 = new TextEncoder(); + +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +function invalidResponse(): never { + throw new TypeError('Invalid control-plane response'); +} + +export function cancelResponseBody(response: Response | undefined): void { + try { + const cancellation = response?.body?.cancel(); + void cancellation?.catch(() => undefined); + } catch { + // Rejection still wins even if a custom Fetch implementation exposes a hostile body. + } +} + +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; +} + +export function exactResponseRecord( + value: unknown, + required: readonly string[], + optional: readonly string[], +): Readonly> { + if (value === null || Array.isArray(value) || typeof value !== 'object') return invalidResponse(); + const keys = Object.keys(value); + const allowed = new Set([...required, ...optional]); + if (Reflect.ownKeys(value).length !== keys.length + || required.some((key) => !Object.hasOwn(value, key)) + || keys.some((key) => !allowed.has(key))) return invalidResponse(); + return value as Readonly>; +} + +export function boundedResponseString(value: unknown, minimum: number, maximum: number): string { + if (typeof value !== 'string' + || hasUnpairedSurrogate(value) + || CONTROL_CHARACTER.test(value)) return invalidResponse(); + const bytes = UTF8.encode(value).byteLength; + if (bytes < minimum || bytes > maximum) return invalidResponse(); + return value; +} + +export function canonicalRelayUrl(value: unknown): string { + const relayUrl = boundedResponseString(value, 1, 2_048); + let parsed: URL; + try { + parsed = new URL(relayUrl); + } catch { + return invalidResponse(); + } + if (parsed.protocol !== 'wss:' + || parsed.hostname === '' + || parsed.username !== '' + || parsed.password !== '' + || parsed.search !== '' + || parsed.hash !== '' + || !parsed.pathname.endsWith('/ws') + || parsed.href !== relayUrl) return invalidResponse(); + return relayUrl; +} + +export function parseResponseJson(input: Uint8Array): JsonValue { + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(input); + } catch { + return invalidResponse(); + } + 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 invalidResponse(); + 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 invalidResponse(); + } + if (typeof decoded !== 'string' + || hasUnpairedSurrogate(decoded) + || UTF8.encode(decoded).byteLength > MAXIMUM_JSON_STRING_BYTES) { + return invalidResponse(); + } + return decoded; + } + if (!escaped && character === '\\') escaped = true; + else escaped = false; + index += 1; + } + return invalidResponse(); + }; + const parseNumber = (): number => { + const match = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(text.slice(index)); + if (match === null) return invalidResponse(); + index += match[0].length; + const number = Number(match[0]); + if (!Number.isFinite(number)) return invalidResponse(); + return number; + }; + const parseValue = (depth: number): JsonValue => { + if (depth > MAXIMUM_JSON_DEPTH) return invalidResponse(); + values += 1; + if (values > MAXIMUM_JSON_VALUES) return invalidResponse(); + 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 invalidResponse(); + result.push(parseValue(depth + 1)); + skipWhitespace(); + if (text[index] === ']') { + index += 1; + return result; + } + if (text[index] !== ',') return invalidResponse(); + 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 invalidResponse(); + const key = parseString(); + if (keys.has(key) || key === '__proto__' || key === 'prototype' || key === 'constructor') { + return invalidResponse(); + } + keys.add(key); + skipWhitespace(); + if (text[index] !== ':') return invalidResponse(); + index += 1; + result[key] = parseValue(depth + 1); + skipWhitespace(); + if (text[index] === '}') { + index += 1; + return result; + } + if (text[index] !== ',') return invalidResponse(); + index += 1; + skipWhitespace(); + } + } + return invalidResponse(); + }; + + skipWhitespace(); + const parsed = parseValue(1); + skipWhitespace(); + if (index !== text.length) return invalidResponse(); + return parsed; +} + +export async function boundedResponseBody( + response: Response, + signal?: AbortSignal, +): Promise { + const contentLength = response.headers.get('content-length'); + if (contentLength !== null + && (!/^(?:0|[1-9][0-9]*)$/.test(contentLength) + || Number(contentLength) > MAXIMUM_RESPONSE_BYTES)) { + cancelResponseBody(response); + return invalidResponse(); + } + if (response.body === null) return invalidResponse(); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + const cancelReader = () => { + try { + void reader.cancel().catch(() => undefined); + } catch { + // The caller's validation or cancellation result remains authoritative. + } + }; + if (signal !== undefined) { + signal.addEventListener('abort', cancelReader, { once: true }); + if (signal.aborted) cancelReader(); + } + try { + while (true) { + const item = await reader.read(); + if (item.done) break; + size += item.value.byteLength; + if (size > MAXIMUM_RESPONSE_BYTES) { + cancelReader(); + return invalidResponse(); + } + chunks.push(item.value); + } + } catch { + return invalidResponse(); + } finally { + signal?.removeEventListener('abort', cancelReader); + reader.releaseLock(); + } + if (size === 0) return invalidResponse(); + const body = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +} diff --git a/src/internal/validation.ts b/src/internal/validation.ts index c4d4a5d..33892e1 100644 --- a/src/internal/validation.ts +++ b/src/internal/validation.ts @@ -19,7 +19,8 @@ import type { BrowserCallOptions, BrowserClientLogger, BrowserClientOptions, - FirebaseIdTokenProvider, + BrowserRelayCredential, + BrowserRelayCredentialProvider, } from '../browser-api.js'; import { LIMITS } from '../protocol/codec.js'; @@ -28,6 +29,7 @@ const CONTROL_CHARACTER = /\p{Cc}/u; const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']); const COORDINATOR_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; const HOME_ID = /^[a-z][a-z0-9-]{1,61}[a-z0-9]$/; +const BASE64URL = /^[A-Za-z0-9_-]+$/; interface ValueBudget { values: number; @@ -404,11 +406,11 @@ function isCoordinatorLogger(value: unknown): value is CoordinatorLogger { && typeof value.write === 'function'; } -function isFirebaseIdTokenProvider(value: unknown): value is FirebaseIdTokenProvider { +function isBrowserRelayCredentialProvider(value: unknown): value is BrowserRelayCredentialProvider { return value !== null && typeof value === 'object' - && 'getIdToken' in value - && typeof value.getIdToken === 'function'; + && 'getCredential' in value + && typeof value.getCredential === 'function'; } function isBrowserClientLogger(value: unknown): value is BrowserClientLogger { @@ -486,29 +488,57 @@ export function validateRelayUrl(value: unknown, label = 'relay URL'): string { return url.href; } -export function validateFirebaseIdToken(value: unknown): string { - return boundedString(value, 1, 16_384, 'Firebase ID token', true); +export function validateBrowserRelayCredential( + value: unknown, + now: number, +): BrowserRelayCredential { + const credential = exactObject( + value, + ['relayUrl', 'accessToken', 'expiresAtMs'], + [], + 'browser relay credential', + ); + const relayUrl = validateRelayUrl(credential.relayUrl, 'browser relay credential relayUrl'); + if (relayUrl !== credential.relayUrl) { + throw new TypeError('browser relay credential relayUrl must be canonical'); + } + const accessToken = boundedString( + credential.accessToken, + 1, + 8_192, + 'browser relay credential accessToken', + ); + const segments = accessToken.split('.'); + if (segments.length !== 3 || segments.some((segment) => !BASE64URL.test(segment))) { + throw new TypeError('browser relay credential accessToken must be compact'); + } + const expiresAtMs = credential.expiresAtMs; + if (typeof expiresAtMs !== 'number' + || !Number.isSafeInteger(expiresAtMs) + || expiresAtMs <= now) { + throw new RangeError('browser relay credential expiry must be a future safe integer'); + } + return Object.freeze({ relayUrl, accessToken, expiresAtMs }); } export function validateBrowserClientOptions(value: unknown): BrowserClientOptions { const options = exactObject( value, - ['homeId', 'relayUrl', 'idTokenProvider'], + ['homeId', 'credentialProvider'], ['logger'], 'options', ); const homeId = boundedString(options.homeId, 3, 63, 'options.homeId'); if (!HOME_ID.test(homeId)) throw new TypeError('options.homeId is invalid'); - if (!isFirebaseIdTokenProvider(options.idTokenProvider)) { - throw new TypeError('options.idTokenProvider must implement getIdToken'); + if (!isBrowserRelayCredentialProvider(options.credentialProvider)) { + throw new TypeError('options.credentialProvider must implement getCredential'); } if (options.logger !== undefined && !isBrowserClientLogger(options.logger)) { throw new TypeError('options.logger must implement write'); } const base = Object.freeze({ homeId, - relayUrl: validateRelayUrl(options.relayUrl, 'options.relayUrl'), - idTokenProvider: options.idTokenProvider, + credentialProvider: options.credentialProvider, }); return options.logger === undefined ? base diff --git a/test/browser-lifecycle.test.ts b/test/browser-lifecycle.test.ts index e7276cd..949b354 100644 --- a/test/browser-lifecycle.test.ts +++ b/test/browser-lifecycle.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from 'bun:test'; -import type { BrowserClientLogRecord, FirebaseIdTokenRequest } from '../src/browser-api.js'; +import type { + BrowserClientLogRecord, + BrowserRelayCredentialRequest, +} from '../src/browser-api.js'; import { createBrowserClientWithRuntime } from '../src/browser-client.js'; import { Opcode } from '../src/protocol/codec.js'; import { FakeRelay } from './fakes/relay.js'; @@ -18,7 +21,7 @@ describe('browser lifecycle', () => { const started = harness.client.start(); const connection = await harness.relay.connectionAt(0); const hello = await connection.nextClientFrame(Opcode.Hello); - expect(hello.payload).toEqual([1, 0, 0, 1, 'firebase-initial', ['test-home']]); + expect(hello.payload).toEqual([1, 0, 0, 1, 'user.initial.signature', ['test-home']]); expect(harness.client.status).toBe('authenticating'); sendUserBootstrap(connection); const ready = await started; @@ -74,15 +77,126 @@ describe('browser lifecycle', () => { const { connection } = await startBrowserReady(harness); await harness.runtime.advanceBy(5_000); const reauth = await connection.nextClientFrame(Opcode.Reauth); - expect(reauth.payload).toEqual([1, 'firebase-reauth']); + expect(reauth.payload).toEqual([1, 'user.reauth.signature']); connection.send({ opcode: Opcode.ReauthOk, payload: [1, 2_000_000] }); await flushMicrotasks(); - expect(harness.tokenRequests.map(({ reason }) => reason)).toEqual(['initial', 'reauth']); + expect(harness.credentialRequests.map(({ reason }) => reason)).toEqual(['initial', 'reauth']); expect(harness.relay.connectCount).toBe(1); expect(harness.client.status).toBe('ready'); await harness.client.stop(); }); + test('hands an issued credential to a changed relay without reexchange or old-socket exposure', async () => { + const relay = new FakeRelay({ autoWelcome: false, expiresAtMs: 1_010_000 }); + const runtime = new FakeRuntime(relay); + const requests: BrowserRelayCredentialRequest[] = []; + const client = createBrowserClientWithRuntime({ + homeId: 'test-home', + credentialProvider: { + async getCredential(request) { + requests.push(request); + return request.reason === 'initial' + ? { + relayUrl: 'wss://old-relay.test/miakapp/ws', + accessToken: 'user.initial.signature', + expiresAtMs: 1_010_000, + } + : { + relayUrl: 'wss://new-relay.test/miakapp/ws', + accessToken: 'user.handoff.signature', + expiresAtMs: 1_100_000, + }; + }, + }, + }, runtime); + + const started = client.start(); + const oldConnection = await relay.connectionAt(0); + const oldHello = await oldConnection.nextClientFrame(Opcode.Hello); + expect(oldHello.payload[4]).toBe('user.initial.signature'); + sendUserBootstrap(oldConnection); + await started; + + await runtime.advanceBy(5_000); + const replacement = await relay.connectionAt(1); + const replacementHello = await replacement.nextClientFrame(Opcode.Hello); + expect(replacementHello.payload[4]).toBe('user.handoff.signature'); + expect(oldConnection.queuedClientFrameCount).toBe(0); + expect(requests.map(({ reason }) => reason)).toEqual(['initial', 'reauth']); + expect(relay.connectUrls).toEqual([ + 'wss://old-relay.test/miakapp/ws', + 'wss://new-relay.test/miakapp/ws', + ]); + expect(relay.socketHighWater).toBe(1); + expect(client.state.snapshot()?.stale).toBe(true); + + sendUserBootstrap(replacement, { revision: 2, state: { 'home.temperature': 24 } }); + await flushMicrotasks(); + expect(client.status).toBe('ready'); + expect(client.state.snapshot()?.values['home.temperature']).toBe(24); + await client.stop(); + }); + + test('rejects a credential that expires while the relay handshake is pending', async () => { + const relay = new FakeRelay({ autoWelcome: false }); + const runtime = new FakeRuntime(relay); + const client = createBrowserClientWithRuntime({ + homeId: 'test-home', + credentialProvider: { + async getCredential() { + return { + relayUrl: 'wss://relay.test/miakapp/ws', + accessToken: 'user.expiring.signature', + expiresAtMs: 1_000_001, + }; + }, + }, + }, runtime); + const started = client.start(); + void started.catch(() => undefined); + const connection = await relay.connectionAt(0); + await connection.nextClientFrame(Opcode.Hello); + await runtime.advanceBy(1); + connection.sendWelcome(); + await flushMicrotasks(); + expect(client.status).toBe('reconnecting'); + expect(relay.openConnectionCount).toBe(0); + await client.stop(); + }); + + test('rejects a REAUTH lease that exceeds the credential returned by the provider', async () => { + const relay = new FakeRelay({ autoWelcome: false, expiresAtMs: 1_010_000 }); + const runtime = new FakeRuntime(relay); + runtime.queueRandom(0); + const client = createBrowserClientWithRuntime({ + homeId: 'test-home', + credentialProvider: { + async getCredential({ reason }) { + return { + relayUrl: 'wss://relay.test/miakapp/ws', + accessToken: `user.${reason}.signature`, + expiresAtMs: reason === 'initial' ? 1_010_000 : 1_020_000, + }; + }, + }, + }, runtime); + const failures: string[] = []; + client.errors.subscribe(({ kind }) => failures.push(kind)); + const started = client.start(); + const connection = await relay.connectionAt(0); + await connection.nextClientFrame(Opcode.Hello); + sendUserBootstrap(connection); + await started; + + await runtime.advanceBy(5_000); + const reauth = await connection.nextClientFrame(Opcode.Reauth); + connection.send({ opcode: Opcode.ReauthOk, payload: [reauth.payload[0] ?? 1, 1_020_001] }); + await flushMicrotasks(); + expect(failures).toContain('protocol'); + expect(client.status).toBe('reconnecting'); + await client.stop(); + }); + test('bounds a missing REAUTH response and reconnects', async () => { const harness = createBrowserTestHarness({ expiresAtMs: 1_040_000 }); harness.runtime.queueRandom(0); @@ -106,12 +220,12 @@ describe('browser lifecycle', () => { await harness.runtime.advanceBy(0); const next = await harness.relay.connectionAt(1); const hello = await next.nextClientFrame(Opcode.Hello); - expect(hello.payload[4]).toBe('firebase-reconnect'); + expect(hello.payload[4]).toBe('user.reconnect.signature'); sendUserBootstrap(next, { revision: 1, state: { 'home.temperature': 22 } }); await flushMicrotasks(); expect(harness.client.status).toBe('ready'); expect(harness.client.state.snapshot()?.values['home.temperature']).toBe(22); - expect(harness.tokenRequests.map(({ reason }) => reason)).toEqual(['initial', 'reconnect']); + expect(harness.credentialRequests.map(({ reason }) => reason)).toEqual(['initial', 'reconnect']); await harness.client.stop(); }); @@ -139,17 +253,16 @@ describe('browser lifecycle', () => { await harness.client.stop(); }); - test('bounds token acquisition and WELCOME phases', async () => { + test('bounds credential acquisition and WELCOME phases', async () => { const relay = new FakeRelay({ autoWelcome: false }); const runtime = new FakeRuntime(relay); - let tokenRequests = 0; + let credentialRequests = 0; const client = createBrowserClientWithRuntime({ homeId: 'test-home', - relayUrl: 'wss://relay.test/ws', - idTokenProvider: { - async getIdToken() { - tokenRequests += 1; - return new Promise(() => undefined); + credentialProvider: { + async getCredential() { + credentialRequests += 1; + return new Promise(() => undefined); }, }, }, runtime); @@ -157,7 +270,7 @@ describe('browser lifecycle', () => { void started.catch(() => undefined); await flushMicrotasks(); await runtime.advanceBy(10_000); - expect(tokenRequests).toBe(2); + expect(credentialRequests).toBe(2); expect(relay.connectCount).toBe(0); await client.stop(); @@ -173,17 +286,16 @@ describe('browser lifecycle', () => { }); test('sanitizes provider failures and log records', async () => { - const secret = 'firebase-secret-from-provider'; + const secret = 'source-secret-from-provider'; const records: BrowserClientLogRecord[] = []; const relay = new FakeRelay(); const runtime = new FakeRuntime(relay); - const tokenRequests: FirebaseIdTokenRequest[] = []; + const credentialRequests: BrowserRelayCredentialRequest[] = []; const client = createBrowserClientWithRuntime({ homeId: 'test-home', - relayUrl: 'wss://relay.test/ws', - idTokenProvider: { - async getIdToken(request) { - tokenRequests.push(request); + credentialProvider: { + async getCredential(request) { + credentialRequests.push(request); throw new Error(secret); }, }, @@ -196,23 +308,56 @@ describe('browser lifecycle', () => { expect(client.status).toBe('reconnecting'); expect(JSON.stringify({ records, failures: failures.map((failure) => failure.message) })) .not.toContain(secret); - expect(tokenRequests).toHaveLength(1); + expect(credentialRequests).toHaveLength(1); await client.stop(); await expect(started).rejects.toMatchObject({ kind: 'cancelled' }); }); - test('does not invoke the token provider after a connecting listener stops reentrantly', async () => { + test('rejects malformed provider credentials before opening a socket without echoing them', async () => { + const secret = 'source credential that must not escape'; + const records: BrowserClientLogRecord[] = []; + const relay = new FakeRelay(); + const runtime = new FakeRuntime(relay); + const client = createBrowserClientWithRuntime({ + homeId: 'test-home', + credentialProvider: { + async getCredential() { + return { + relayUrl: 'ws://relay.test/ws', + accessToken: secret, + expiresAtMs: 2_000_000, + }; + }, + }, + logger: { write: (record) => records.push(record) }, + }, runtime); + const failures: Error[] = []; + client.errors.subscribe((failure) => failures.push(failure)); + const started = client.start(); + void started.catch(() => undefined); + await flushMicrotasks(); + expect(client.status).toBe('reconnecting'); + expect(relay.connectCount).toBe(0); + expect(JSON.stringify({ records, failures: failures.map(({ message }) => message) })) + .not.toContain(secret); + await client.stop(); + }); + + test('does not invoke the credential provider after a connecting listener stops reentrantly', async () => { const relay = new FakeRelay(); const runtime = new FakeRuntime(relay); - let tokenRequests = 0; + let credentialRequests = 0; const statuses: string[] = []; const client = createBrowserClientWithRuntime({ homeId: 'test-home', - relayUrl: 'wss://relay.test/ws', - idTokenProvider: { - async getIdToken() { - tokenRequests += 1; - return 'firebase-token'; + credentialProvider: { + async getCredential() { + credentialRequests += 1; + return { + relayUrl: 'wss://relay.test/ws', + accessToken: 'user.initial.signature', + expiresAtMs: 2_000_000, + }; }, }, }, runtime); @@ -225,7 +370,7 @@ describe('browser lifecycle', () => { await expect(started).rejects.toMatchObject({ kind: 'cancelled' }); await client.stop(); - expect(tokenRequests).toBe(0); + expect(credentialRequests).toBe(0); expect(relay.connectCount).toBe(0); expect(statuses).toEqual(['connecting', 'stopping', 'stopped']); }); diff --git a/test/browser-public-api.test.ts b/test/browser-public-api.test.ts index 064d75f..a56ebb5 100644 --- a/test/browser-public-api.test.ts +++ b/test/browser-public-api.test.ts @@ -8,6 +8,7 @@ import { createBrowserTestHarness, startBrowserReady } from './fakes/user-relay. describe('browser public API', () => { test('exports an isolated browser surface and constructs inertly', () => { expect(typeof browserEntrypoint.createBrowserClient).toBe('function'); + expect(typeof browserEntrypoint.createControlPlaneBrowserRelayCredentialProvider).toBe('function'); expect('createCoordinator' in browserEntrypoint).toBe(false); expect('createHomeKeyAccessTokenProvider' in browserEntrypoint).toBe(false); @@ -23,28 +24,45 @@ describe('browser public API', () => { const runtime = new FakeRuntime(relay); const valid = { homeId: 'test-home', - relayUrl: 'wss://relay.test/ws', - idTokenProvider: { async getIdToken() { return 'token'; } }, + credentialProvider: { + async getCredential() { + return { + relayUrl: 'wss://relay.test/ws', + accessToken: 'user.initial.signature', + expiresAtMs: 2_000_000, + }; + }, + }, }; expect(() => createBrowserClientWithRuntime({ ...valid, secret: 'forbidden' } as never, runtime)) .toThrow(/invalid shape/); expect(() => createBrowserClientWithRuntime({ ...valid, homeId: '../bad' }, runtime)) .toThrow(/homeId/); - expect(() => createBrowserClientWithRuntime({ ...valid, relayUrl: 'ws://relay.test/ws' }, runtime)) - .toThrow(/secure WebSocket/); + expect(() => createBrowserClientWithRuntime({ ...valid, credentialProvider: {} } as never, runtime)) + .toThrow(/getCredential/); + expect(() => createBrowserClientWithRuntime({ + homeId: 'test-home', + relayUrl: 'wss://relay.test/ws', + idTokenProvider: { async getIdToken() { return 'firebase.header.signature'; } }, + } as never, runtime)).toThrow(/invalid shape/); expect(relay.connections).toHaveLength(0); }); test('supports class providers and idempotent bounded cleanup', async () => { class Provider { - async getIdToken(): Promise { return 'class-token'; } + async getCredential() { + return { + relayUrl: 'wss://relay.test/ws', + accessToken: 'class.token.signature', + expiresAtMs: 2_000_000, + }; + } } const relay = new FakeRelay({ autoWelcome: false }); const runtime = new FakeRuntime(relay); const client = createBrowserClientWithRuntime({ homeId: 'test-home', - relayUrl: 'wss://relay.test/ws', - idTokenProvider: new Provider(), + credentialProvider: new Provider(), }, runtime); const first = client.stop({ deadlineMs: 0 }); const second = client.stop({ deadlineMs: 1 }); diff --git a/test/browser-relay-credential-provider.test.ts b/test/browser-relay-credential-provider.test.ts new file mode 100644 index 0000000..ef305bd --- /dev/null +++ b/test/browser-relay-credential-provider.test.ts @@ -0,0 +1,372 @@ +import { describe, expect, test } from 'bun:test'; +import type { + BrowserRelayCredentialRequest, + ControlPlaneBrowserRelayCredentialProviderOptions, +} from '../src/browser-api.js'; +import { createBrowserClientWithRuntime } from '../src/browser-client.js'; +import { createControlPlaneBrowserRelayCredentialProvider } from '../src/browser-relay-credential-provider.js'; +import { Opcode } from '../src/protocol/codec.js'; +import { FakeRelay } from './fakes/relay.js'; +import { FakeRuntime, flushMicrotasks } from './fakes/runtime.js'; +import { sendUserBootstrap } from './fakes/user-relay.js'; + +const ENDPOINT = 'https://control.example.test/v1/user-relay-tokens:exchange'; +const FIREBASE_ID_TOKEN = 'firebase.header.signature'; +const APP_CHECK_TOKEN = 'appcheck.header.signature'; +const ACCESS_TOKEN = 'miakapp.header.signature'; + +function successHeaders(): Record { + return { + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=utf-8', + pragma: 'no-cache', + }; +} + +function successBody(overrides: Record = {}): Record { + return { + schema: 'miakapp.user-relay-token/1', + access_token: ACCESS_TOKEN, + token_type: 'Bearer', + expires_at_ms: Date.now() + 300_000, + relay_url: 'wss://relay.example.test/miakapp/ws', + ...overrides, + }; +} + +function response(body: string, init: ResponseInit = {}): Response { + return new Response(body, { + status: 200, + headers: successHeaders(), + ...init, + }); +} + +function fetcher( + implementation: (input: string, init: RequestInit) => Promise, +): typeof globalThis.fetch { + return implementation as typeof globalThis.fetch; +} + +function options( + fetchImplementation: (input: string, init: RequestInit) => Promise, +): ControlPlaneBrowserRelayCredentialProviderOptions { + return { + exchangeEndpoint: ENDPOINT, + async getFirebaseIdToken() { return FIREBASE_ID_TOKEN; }, + async getAppCheckToken() { return APP_CHECK_TOKEN; }, + fetch: fetcher(fetchImplementation), + }; +} + +function request( + reason: BrowserRelayCredentialRequest['reason'] = 'initial', + signal = new AbortController().signal, +): BrowserRelayCredentialRequest { + return { homeId: 'test-home', reason, signal }; +} + +describe('control-plane browser relay credential provider', () => { + test('performs one closed source exchange and returns only the atomic relay credential', async () => { + const callbackRequests: BrowserRelayCredentialRequest[] = []; + const calls: Array<{ input: string; init: RequestInit }> = []; + const provider = createControlPlaneBrowserRelayCredentialProvider({ + exchangeEndpoint: ENDPOINT, + async getFirebaseIdToken(value) { + callbackRequests.push(value); + return FIREBASE_ID_TOKEN; + }, + async getAppCheckToken(value) { + callbackRequests.push(value); + return APP_CHECK_TOKEN; + }, + fetch: fetcher(async (input, init) => { + calls.push({ input, init }); + return response(JSON.stringify(successBody())); + }), + }); + + const result = await provider.getCredential(request('reauth')); + expect(result).toEqual({ + relayUrl: 'wss://relay.example.test/miakapp/ws', + accessToken: ACCESS_TOKEN, + expiresAtMs: expect.any(Number), + }); + expect(Object.isFrozen(result)).toBe(true); + expect(JSON.stringify(result)).not.toContain(FIREBASE_ID_TOKEN); + expect(JSON.stringify(result)).not.toContain(APP_CHECK_TOKEN); + expect(callbackRequests).toHaveLength(2); + expect(callbackRequests[0]).toBe(callbackRequests[1]); + expect(Object.isFrozen(callbackRequests[0])).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 ${FIREBASE_ID_TOKEN}`, + 'content-type': 'application/json', + 'x-firebase-appcheck': APP_CHECK_TOKEN, + })); + expect(JSON.parse(call.init.body as string)).toEqual({ + home_id: 'test-home', + reason: 'reauth', + }); + }); + + test('confines source tokens to HTTPS and puts only the exchanged token in HELLO', async () => { + const provider = createControlPlaneBrowserRelayCredentialProvider(options( + async () => response(JSON.stringify(successBody({ + relay_url: 'wss://relay.test/miakapp/ws', + }))), + )); + const relay = new FakeRelay({ autoWelcome: false }); + const runtime = new FakeRuntime(relay); + const client = createBrowserClientWithRuntime({ + homeId: 'test-home', + credentialProvider: provider, + }, runtime); + + const started = client.start(); + const connection = await relay.connectionAt(0); + const hello = await connection.nextClientFrame(Opcode.Hello); + expect(hello.payload[4]).toBe(ACCESS_TOKEN); + expect(JSON.stringify(hello.payload)).not.toContain(FIREBASE_ID_TOKEN); + expect(JSON.stringify(hello.payload)).not.toContain(APP_CHECK_TOKEN); + sendUserBootstrap(connection); + await started; + await client.stop(); + }); + + test('is inert at construction, performs no hidden retry, and sanitizes failures', async () => { + const secret = `${FIREBASE_ID_TOKEN}:${APP_CHECK_TOKEN}`; + let calls = 0; + const provider = createControlPlaneBrowserRelayCredentialProvider(options(async () => { + calls += 1; + throw new Error(secret); + })); + expect(calls).toBe(0); + const failure = await provider.getCredential(request()).catch((error: unknown) => error); + expect(calls).toBe(1); + expect(failure).toBeInstanceOf(Error); + expect(String(failure)).toBe('Error: Miakapp browser relay credential exchange failed'); + expect(String(failure)).not.toContain(secret); + }); + + test('rejects malformed configuration and request shapes before callbacks or network', async () => { + const invalidConfigurations: ControlPlaneBrowserRelayCredentialProviderOptions[] = [ + { ...options(async () => response('{}')), exchangeEndpoint: ENDPOINT.replace('https:', 'http:') }, + { ...options(async () => response('{}')), exchangeEndpoint: `${ENDPOINT}?relay=other` }, + { ...options(async () => response('{}')), exchangeEndpoint: 'https://user@control.example.test/v1/user-relay-tokens:exchange' }, + ]; + for (const configuration of invalidConfigurations) { + expect(() => createControlPlaneBrowserRelayCredentialProvider(configuration)) + .toThrow('Miakapp browser relay credential exchange failed'); + } + expect(() => createControlPlaneBrowserRelayCredentialProvider({ + ...options(async () => response('{}')), + unknown: true, + } as ControlPlaneBrowserRelayCredentialProviderOptions)) + .toThrow('Miakapp browser relay credential exchange failed'); + const hostileConfiguration = Object.create(null) as Record; + Object.defineProperty(hostileConfiguration, 'exchangeEndpoint', { + enumerable: true, + get() { throw new Error(FIREBASE_ID_TOKEN); }, + }); + Object.defineProperty(hostileConfiguration, 'getFirebaseIdToken', { + enumerable: true, + value: async () => FIREBASE_ID_TOKEN, + }); + Object.defineProperty(hostileConfiguration, 'getAppCheckToken', { + enumerable: true, + value: async () => APP_CHECK_TOKEN, + }); + expect(() => createControlPlaneBrowserRelayCredentialProvider( + hostileConfiguration as unknown as ControlPlaneBrowserRelayCredentialProviderOptions, + )).toThrow('Miakapp browser relay credential exchange failed'); + + let callbacks = 0; + let calls = 0; + const provider = createControlPlaneBrowserRelayCredentialProvider({ + exchangeEndpoint: ENDPOINT, + async getFirebaseIdToken() { callbacks += 1; return FIREBASE_ID_TOKEN; }, + async getAppCheckToken() { callbacks += 1; return APP_CHECK_TOKEN; }, + fetch: fetcher(async () => { calls += 1; return response('{}'); }), + }); + const invalidRequests = [ + { homeId: '../bad', reason: 'initial', signal: new AbortController().signal }, + { homeId: 'test-home', reason: 'other', signal: new AbortController().signal }, + { homeId: 'test-home', reason: 'initial', signal: {} }, + { ...request(), unknown: true }, + ]; + for (const invalid of invalidRequests) { + await expect(provider.getCredential(invalid as BrowserRelayCredentialRequest)) + .rejects.toThrow('Miakapp browser relay credential exchange failed'); + } + expect(callbacks).toBe(0); + expect(calls).toBe(0); + }); + + test('rejects invalid source tokens before the next source or network boundary', async () => { + let appCheckCalls = 0; + let fetchCalls = 0; + const provider = createControlPlaneBrowserRelayCredentialProvider({ + exchangeEndpoint: ENDPOINT, + async getFirebaseIdToken() { return 'not a compact token'; }, + async getAppCheckToken() { appCheckCalls += 1; return APP_CHECK_TOKEN; }, + fetch: fetcher(async () => { fetchCalls += 1; return response('{}'); }), + }); + await expect(provider.getCredential(request())) + .rejects.toThrow('Miakapp browser relay credential exchange failed'); + expect(appCheckCalls).toBe(0); + expect(fetchCalls).toBe(0); + }); + + test('rejects open, duplicate, reflected, stale, or mismatched response shapes', async () => { + const valid = successBody(); + const malformedBodies = [ + JSON.stringify({ ...valid, unknown: true }), + JSON.stringify({ ...valid, schema: 'other' }), + JSON.stringify({ ...valid, access_token: 'not-a-compact-token' }), + JSON.stringify({ ...valid, access_token: FIREBASE_ID_TOKEN }), + JSON.stringify({ ...valid, access_token: APP_CHECK_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' }), + `{"schema":"miakapp.user-relay-token/1","schema":"miakapp.user-relay-token/1","access_token":"${ACCESS_TOKEN}","token_type":"Bearer","expires_at_ms":${Date.now() + 300_000},"relay_url":"wss://relay.example.test/ws"}`, + `{"schema":"miakapp.user-relay-token/1","access_token":"${ACCESS_TOKEN}","token_type":"Bearer","expires_at_ms":${Date.now() + 300_000},"relay_url":"\\ud800"}`, + 'x'.repeat(65_537), + ]; + for (const body of malformedBodies) { + const provider = createControlPlaneBrowserRelayCredentialProvider(options( + async () => response(body), + )); + await expect(provider.getCredential(request())) + .rejects.toThrow('Miakapp browser relay credential exchange failed'); + } + }); + + test('requires a successful non-redirected no-store JSON response', async () => { + const cases: Response[] = [ + new Response(JSON.stringify(successBody()), { + status: 503, + headers: successHeaders(), + }), + new Response(JSON.stringify(successBody()), { + status: 200, + headers: { 'content-type': 'application/json', pragma: 'no-cache' }, + }), + new Response(JSON.stringify(successBody()), { + status: 200, + headers: { 'cache-control': 'no-store', 'content-type': 'text/plain', pragma: 'no-cache' }, + }), + new Response(JSON.stringify(successBody()), { + status: 200, + headers: { 'cache-control': 'no-store', 'content-type': 'application/json' }, + }), + ]; + const redirected = response(JSON.stringify(successBody())); + Object.defineProperty(redirected, 'redirected', { value: true }); + cases.push(redirected); + for (const invalidResponse of cases) { + const provider = createControlPlaneBrowserRelayCredentialProvider(options( + async () => invalidResponse, + )); + await expect(provider.getCredential(request())) + .rejects.toThrow('Miakapp browser relay credential exchange failed'); + } + }); + + test('coalesces only identical in-flight requests', async () => { + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { release = resolve; }); + let callbacks = 0; + let calls = 0; + const provider = createControlPlaneBrowserRelayCredentialProvider({ + exchangeEndpoint: ENDPOINT, + async getFirebaseIdToken() { callbacks += 1; return FIREBASE_ID_TOKEN; }, + async getAppCheckToken() { callbacks += 1; return APP_CHECK_TOKEN; }, + fetch: fetcher(async () => { + calls += 1; + await gate; + return response(JSON.stringify(successBody())); + }), + }); + const signal = new AbortController().signal; + const first = provider.getCredential(request('initial', signal)); + const second = provider.getCredential(request('initial', signal)); + await flushMicrotasks(); + expect(callbacks).toBe(2); + expect(calls).toBe(1); + release?.(); + await expect(Promise.all([first, second])).resolves.toHaveLength(2); + + await provider.getCredential(request('reconnect', signal)); + await provider.getCredential(request('initial', new AbortController().signal)); + expect(calls).toBe(3); + }); + + test('settles promptly with the exact cancellation reason even when a callback ignores it', async () => { + const controller = new AbortController(); + const cancellation = new Error('synthetic cancellation'); + let callbacks = 0; + const provider = createControlPlaneBrowserRelayCredentialProvider({ + exchangeEndpoint: ENDPOINT, + async getFirebaseIdToken() { + callbacks += 1; + return new Promise(() => undefined); + }, + async getAppCheckToken() { callbacks += 1; return APP_CHECK_TOKEN; }, + fetch: fetcher(async () => response('{}')), + }); + const pending = provider.getCredential(request('initial', controller.signal)); + await flushMicrotasks(); + controller.abort(cancellation); + await expect(pending).rejects.toBe(cancellation); + expect(callbacks).toBe(1); + }); + + test('cancels a response stream that ignores the fetch signal', async () => { + const controller = new AbortController(); + const cancellation = new Error('synthetic stream cancellation'); + let cancellations = 0; + const provider = createControlPlaneBrowserRelayCredentialProvider(options(async () => ( + new Response(new ReadableStream({ + pull() { return new Promise(() => undefined); }, + cancel() { cancellations += 1; }, + }), { status: 200, headers: successHeaders() }) + ))); + const pending = provider.getCredential(request('initial', controller.signal)); + await flushMicrotasks(); + controller.abort(cancellation); + await expect(pending).rejects.toBe(cancellation); + await flushMicrotasks(); + expect(cancellations).toBe(1); + }); + + test('cancels an unread response stream when its declared length is invalid', async () => { + let cancellations = 0; + const provider = createControlPlaneBrowserRelayCredentialProvider(options(async () => ( + new Response(new ReadableStream({ + pull() { return new Promise(() => undefined); }, + cancel() { + cancellations += 1; + return new Promise(() => undefined); + }, + }), { + status: 200, + headers: { ...successHeaders(), 'content-length': '65537' }, + }) + ))); + await expect(provider.getCredential(request())) + .rejects.toThrow('Miakapp browser relay credential exchange failed'); + expect(cancellations).toBe(1); + }); +}); diff --git a/test/fakes/relay.ts b/test/fakes/relay.ts index df8b5c4..1da896b 100644 --- a/test/fakes/relay.ts +++ b/test/fakes/relay.ts @@ -273,6 +273,7 @@ export class FakeRelay implements SocketFactory { readonly #connections: FakeRelayConnection[] = []; readonly #connectionWaiters: Array<(connection: FakeRelayConnection) => void> = []; readonly #connectErrors: Error[] = []; + readonly #connectUrls: string[] = []; #openConnections = 0; #socketHighWater = 0; @@ -303,16 +304,21 @@ export class FakeRelay implements SocketFactory { return this.#openConnections; } + get connectUrls(): readonly string[] { + return this.#connectUrls; + } + queueConnectError(error = new Error('Synthetic connection failure')): void { this.#connectErrors.push(error); } async connect( - _url: string, + url: string, handlers: SocketHandlers, signal: AbortSignal, ): Promise { if (signal.aborted) throw signal.reason; + this.#connectUrls.push(url); const failure = this.#connectErrors.shift(); if (failure !== undefined) throw failure; const connectionIndex = this.#connections.length; diff --git a/test/fakes/user-relay.ts b/test/fakes/user-relay.ts index 9254277..aad7ab7 100644 --- a/test/fakes/user-relay.ts +++ b/test/fakes/user-relay.ts @@ -2,7 +2,7 @@ import type { BrowserClient, BrowserClientLogger, BrowserReadySession, - FirebaseIdTokenRequest, + BrowserRelayCredentialRequest, } from '../../src/browser-api.js'; import { createBrowserClientWithRuntime } from '../../src/browser-client.js'; import { Opcode, type ProtocolValue } from '../../src/protocol/codec.js'; @@ -19,7 +19,7 @@ export interface BrowserTestHarness { readonly client: BrowserClient; readonly relay: FakeRelay; readonly runtime: FakeRuntime; - readonly tokenRequests: FirebaseIdTokenRequest[]; + readonly credentialRequests: BrowserRelayCredentialRequest[]; } function dictionary(names: readonly string[], firstId: number): ProtocolValue[] { @@ -59,14 +59,17 @@ export function createBrowserTestHarness( ): BrowserTestHarness { const relay = new FakeRelay({ ...relayOptions, autoWelcome: false }); const runtime = new FakeRuntime(relay); - const tokenRequests: FirebaseIdTokenRequest[] = []; + const credentialRequests: BrowserRelayCredentialRequest[] = []; const baseOptions = { homeId: 'test-home', - relayUrl: 'wss://relay.test/miakapp/ws', - idTokenProvider: { - async getIdToken(request: FirebaseIdTokenRequest): Promise { - tokenRequests.push(request); - return `firebase-${request.reason}`; + credentialProvider: { + async getCredential(request: BrowserRelayCredentialRequest) { + credentialRequests.push(request); + return { + relayUrl: 'wss://relay.test/miakapp/ws', + accessToken: `user.${request.reason}.signature`, + expiresAtMs: 2_000_000, + }; }, }, }; @@ -74,7 +77,7 @@ export function createBrowserTestHarness( logger === undefined ? baseOptions : { ...baseOptions, logger }, runtime, ); - return { client, relay, runtime, tokenRequests }; + return { client, relay, runtime, credentialRequests }; } export async function startBrowserReady( diff --git a/test/integration/browser.ts b/test/integration/browser.ts index 50d0fde..6f1e695 100644 --- a/test/integration/browser.ts +++ b/test/integration/browser.ts @@ -1,7 +1,7 @@ import type { BrowserClientFailure, BrowserClientStatus, - FirebaseIdTokenReason, + BrowserRelayCredentialReason, } from '../../src/browser.js'; import { createBrowserClient } from '../../src/browser.js'; @@ -21,7 +21,7 @@ interface BrowserIntegration { start(): Promise<{ enrolled: boolean; coordinatorCount: number }>; state(): BrowserIntegrationState | undefined; call(target: number): Promise; - tokenReasons(): readonly FirebaseIdTokenReason[]; + credentialReasons(): readonly BrowserRelayCredentialReason[]; statuses(): readonly BrowserClientStatus[]; failures(): readonly BrowserIntegrationFailure[]; stop(): Promise; @@ -33,19 +33,22 @@ interface BrowserGlobal { } const browserGlobal = globalThis as unknown as BrowserGlobal; -const tokenReasons: FirebaseIdTokenReason[] = []; +const credentialReasons: BrowserRelayCredentialReason[] = []; const statuses: BrowserClientStatus[] = []; const failures: BrowserIntegrationFailure[] = []; const client = createBrowserClient({ homeId: 'integration-home', - relayUrl: `wss://${browserGlobal.location.host}/ws`, - idTokenProvider: { - async getIdToken({ reason, signal }) { + credentialProvider: { + async getCredential({ reason, signal }) { if (signal.aborted) throw signal.reason; - tokenReasons.push(reason); - return reason === 'initial' - ? 'integration-user-token' - : 'integration-user-token-new'; + credentialReasons.push(reason); + return { + relayUrl: `wss://${browserGlobal.location.host}/ws`, + accessToken: reason === 'initial' + ? 'integration.user.initial' + : 'integration.user.renewed', + expiresAtMs: Date.now() + 60_000, + }; }, }, }); @@ -84,7 +87,7 @@ browserGlobal.miakappIntegration = Object.freeze({ await call.accepted; return call.result; }, - tokenReasons: () => Object.freeze([...tokenReasons]), + credentialReasons: () => Object.freeze([...credentialReasons]), statuses: () => Object.freeze([...statuses]), failures: () => Object.freeze([...failures]), stop: () => client.stop({ deadlineMs: 2_000 }), diff --git a/test/node-smoke.mjs b/test/node-smoke.mjs index 3114a14..00058e2 100644 --- a/test/node-smoke.mjs +++ b/test/node-smoke.mjs @@ -42,10 +42,9 @@ assert.equal(coordinator.status, 'stopped'); const browser = createBrowserClient({ homeId: 'node-smoke-home', - relayUrl: 'wss://relay.example.test/miakapp/ws', - idTokenProvider: { - async getIdToken() { - throw new Error('The inert smoke test must not request a Firebase token'); + credentialProvider: { + async getCredential() { + throw new Error('The inert smoke test must not request a browser relay credential'); }, }, }); diff --git a/test/type-contract.ts b/test/type-contract.ts index 5a28d89..948f054 100644 --- a/test/type-contract.ts +++ b/test/type-contract.ts @@ -28,10 +28,13 @@ const options: CoordinatorOptions = { const moduleSurface: CoordinatorModule = { createCoordinator }; const browserOptions: BrowserClientOptions = { homeId: 'type-contract-home', - relayUrl: 'wss://relay.example.test/miakapp/ws', - idTokenProvider: { - async getIdToken() { - return 'firebase-id-token'; + credentialProvider: { + async getCredential() { + return { + relayUrl: 'wss://relay.example.test/miakapp/ws', + accessToken: 'type.contract.signature', + expiresAtMs: Date.now() + 60_000, + }; }, }, };