Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 41 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand Down
241 changes: 14 additions & 227 deletions src/access-token-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string>();
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<Uint8Array> {
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)
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading