/** * Companion token CUSTODY — pure lifecycle logic over an INJECTED keychain adapter. * * Phase 3 of the Companion App build plan (feat/companion-app). * See docs/COMPANION-APP-PHASE-3-OAUTH-PKCE.md for the accepted design and the custody/rotation * rules this module encodes. * * WHAT THIS MODULE IS * The decision + orchestration layer for what the companion stores at rest and when it rotates * or clears it. It governs three secrets: * 1. the OAuth access token (the JWT the companion presents to the hosted gateway/canister), * 2. the OAuth refresh token (opaque; used to mint a new JWT without a fresh browser login), * 3. the Phase 2 per-session LOOPBACK bearer token (custody of which this phase defines — * the loopback inference endpoint's credential, generated by the companion at start). * It also stores non-secret session metadata (expiry, refresh ceiling, scope, issuer) needed to * drive the refresh decision (decideTokenRefresh, from companion-oauth-pkce.mjs). * * WHAT THIS MODULE IS NOT * - It performs NO real OS-keychain I/O. Every read/write/delete is delegated to an INJECTED * adapter (`{ get, set, delete }`). Phase 5 supplies the real adapter backed by macOS * Keychain / Windows DPAPI / Linux libsecret; tests supply an in-memory fake. This keeps the * custody LOGIC pure and exhaustively testable, and keeps the gate's "no real keychain in * this phase" line intact. * - It NEVER writes a secret to a plaintext file, and NEVER logs a token, JWT, refresh token, * loopback token, or any error message containing one. Thrown errors carry fixed messages. * * THREAT MODEL (custody-specific; full model in the design doc): * - JWT/refresh theft at rest → only the OS keychain is used (never a dotfile / env / log). * The adapter is the sole persistence path; this module hands the adapter only the named * secret, never a derived plaintext copy. * - Refresh-token replay → on a detected reuse/`invalid_grant` the caller invokes clearSession() * which removes BOTH tokens (the family is dead; force a fresh login). * - Loopback-token leakage → the loopback token is per-session and rotated at each companion * start (rotateLoopbackToken); it is stored under its own account, separate from the JWT, so * a compromise of one is not automatically a compromise of the other. * * DESIGN CONSTRAINTS: * - Adapter calls are always awaited, so a synchronous OR Promise-returning adapter both work * (awaiting a non-Promise is a no-op) — one code path drives tests and every real OS backend. * - FAIL-CLOSED: a partial/absent session loads as null (→ caller treats as 'reauth'); invalid * inputs to store* throw a fixed-message error rather than persisting something unusable. */ import { decideTokenRefresh } from './companion-oauth-pkce.mjs'; /** * Keychain account names (the "service"/"account" keys under which secrets are stored). * Stable, namespaced, and non-secret. Phase 5's real adapter maps these to Keychain/DPAPI/ * libsecret entries. * @readonly */ export const KEYCHAIN_ACCOUNTS = Object.freeze({ ACCESS_TOKEN: 'knowtation.companion.accessToken', REFRESH_TOKEN: 'knowtation.companion.refreshToken', SESSION_META: 'knowtation.companion.sessionMeta', LOOPBACK_TOKEN: 'knowtation.companion.loopbackToken', }); /** Upper bound on any single stored secret (defense against an unbounded write). */ const MAX_SECRET_LEN = 8192; /** Upper bound on the serialized metadata blob. */ const MAX_META_LEN = 8192; /** * Validate that an injected adapter exposes the required interface. Throws a fixed-message error * (carrying no secret) if not. * @param {unknown} keychain * @returns {{ get: Function, set: Function, delete: Function }} */ function requireAdapter(keychain) { if ( !keychain || typeof keychain !== 'object' || typeof (/** @type {any} */ (keychain).get) !== 'function' || typeof (/** @type {any} */ (keychain).set) !== 'function' || typeof (/** @type {any} */ (keychain).delete) !== 'function' ) { throw new TypeError('createTokenCustody: keychain adapter must implement { get, set, delete }'); } return /** @type {{ get: Function, set: Function, delete: Function }} */ (keychain); } /** * Validate a secret string before it is handed to the adapter. * @param {unknown} value * @param {string} label - used only in the fixed error message (never the value) * @returns {string} */ function requireSecret(value, label) { if (typeof value !== 'string' || value.length === 0 || value.length > MAX_SECRET_LEN) { throw new TypeError(`token-custody: ${label} must be a non-empty string within length bounds`); } return value; } /** * Build a non-secret session-metadata record from a validated token response (the result of * companion-oauth-pkce.validateTokenResponse) plus the current clock. PURE. * * Computes: * - expiresAt = now + expiresIn*1000 (epoch-ms), * - refreshExpiresAt = now + refreshTtlMs when a refresh token exists and a TTL is supplied * (the absolute ceiling after which a refresh is no longer attempted), * - scope, tokenType, and optional issuer for audit/refresh decisions. * * @param {{ expiresIn: number, refreshToken: string | null, scope: string | null, tokenType: string }} tokenResponse * @param {{ now: number, refreshTtlMs?: number, issuer?: string }} ctx * @returns {{ expiresAt: number, refreshExpiresAt: number | null, scope: string | null, tokenType: string, issuer: string | null, storedAt: number }} */ export function buildSessionMeta(tokenResponse, ctx) { const tr = tokenResponse ?? {}; const now = ctx?.now; if (typeof now !== 'number' || !Number.isFinite(now)) { throw new TypeError('buildSessionMeta: now must be a finite number'); } if (typeof tr.expiresIn !== 'number' || !Number.isInteger(tr.expiresIn) || tr.expiresIn <= 0) { throw new TypeError('buildSessionMeta: tokenResponse.expiresIn must be a positive integer'); } const hasRefresh = typeof tr.refreshToken === 'string' && tr.refreshToken.length > 0; const refreshTtlMs = ctx?.refreshTtlMs; const refreshExpiresAt = hasRefresh && typeof refreshTtlMs === 'number' && Number.isFinite(refreshTtlMs) && refreshTtlMs > 0 ? now + refreshTtlMs : null; return { expiresAt: now + tr.expiresIn * 1000, refreshExpiresAt, scope: typeof tr.scope === 'string' ? tr.scope : null, tokenType: typeof tr.tokenType === 'string' ? tr.tokenType : 'Bearer', issuer: typeof ctx?.issuer === 'string' && ctx.issuer.length > 0 ? ctx.issuer : null, storedAt: now, }; } /** * Create a token-custody handle bound to an injected keychain adapter. * * @param {{ get: (account: string) => (string|null|Promise), * set: (account: string, secret: string) => (void|Promise), * delete: (account: string) => (void|Promise) }} keychain * @returns {{ * storeSession: (args: { accessToken: string, refreshToken?: string|null, meta: object }) => Promise, * loadSession: () => Promise, * clearSession: () => Promise, * decide: (args: { now: number, skewMs?: number }) => Promise<'valid'|'refresh'|'reauth'>, * updateAccessToken: (args: { accessToken: string, meta: object, refreshToken?: string|null }) => Promise, * storeLoopbackToken: (token: string) => Promise, * getLoopbackToken: () => Promise, * rotateLoopbackToken: (token: string) => Promise, * clearLoopbackToken: () => Promise, * }} */ export function createTokenCustody(keychain) { const kc = requireAdapter(keychain); /** @param {object} meta */ function serializeMeta(meta) { if (!meta || typeof meta !== 'object') { throw new TypeError('token-custody: session meta must be an object'); } if (typeof meta.expiresAt !== 'number' || !Number.isFinite(meta.expiresAt)) { throw new TypeError('token-custody: session meta.expiresAt must be a finite number'); } const json = JSON.stringify(meta); if (json.length > MAX_META_LEN) { throw new TypeError('token-custody: session meta exceeds length bounds'); } return json; } /** * Persist a freshly-acquired session: the JWT, the optional refresh token, and the metadata. * Overwrites any prior session for these accounts. */ async function storeSession({ accessToken, refreshToken = null, meta } = {}) { const at = requireSecret(accessToken, 'accessToken'); const metaJson = serializeMeta(meta); await kc.set(KEYCHAIN_ACCOUNTS.ACCESS_TOKEN, at); if (refreshToken === null || refreshToken === undefined) { // No refresh token in this grant — ensure no stale one survives. await kc.delete(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN); } else { await kc.set(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN, requireSecret(refreshToken, 'refreshToken')); } await kc.set(KEYCHAIN_ACCOUNTS.SESSION_META, metaJson); } /** * Replace the access token (and metadata) after a successful refresh, rotating the refresh * token when the server returns a new one (rotation). The prior refresh token is overwritten. */ async function updateAccessToken({ accessToken, meta, refreshToken } = {}) { const at = requireSecret(accessToken, 'accessToken'); const metaJson = serializeMeta(meta); await kc.set(KEYCHAIN_ACCOUNTS.ACCESS_TOKEN, at); if (refreshToken !== undefined) { if (refreshToken === null) { await kc.delete(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN); } else { await kc.set(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN, requireSecret(refreshToken, 'refreshToken')); } } await kc.set(KEYCHAIN_ACCOUNTS.SESSION_META, metaJson); } /** * Load the current session. Returns null (fail-closed) if the access token or metadata is * missing or unparsable — the caller then treats it as 'reauth'. */ async function loadSession() { const accessToken = await kc.get(KEYCHAIN_ACCOUNTS.ACCESS_TOKEN); if (typeof accessToken !== 'string' || accessToken.length === 0) return null; const metaRaw = await kc.get(KEYCHAIN_ACCOUNTS.SESSION_META); if (typeof metaRaw !== 'string' || metaRaw.length === 0 || metaRaw.length > MAX_META_LEN) return null; let meta; try { meta = JSON.parse(metaRaw); } catch { return null; } if (!meta || typeof meta !== 'object' || typeof meta.expiresAt !== 'number' || !Number.isFinite(meta.expiresAt)) { return null; } const refreshTokenRaw = await kc.get(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN); const refreshToken = typeof refreshTokenRaw === 'string' && refreshTokenRaw.length > 0 ? refreshTokenRaw : null; return { accessToken, refreshToken, expiresAt: meta.expiresAt, refreshExpiresAt: typeof meta.refreshExpiresAt === 'number' && Number.isFinite(meta.refreshExpiresAt) ? meta.refreshExpiresAt : null, scope: typeof meta.scope === 'string' ? meta.scope : null, tokenType: typeof meta.tokenType === 'string' ? meta.tokenType : 'Bearer', issuer: typeof meta.issuer === 'string' ? meta.issuer : null, }; } /** * Remove the OAuth session secrets (access + refresh + metadata). Idempotent. Used on logout * and on refresh-reuse / invalid_grant (family is dead → force a fresh login). Does NOT touch * the loopback token, which has an independent lifecycle. */ async function clearSession() { await kc.delete(KEYCHAIN_ACCOUNTS.ACCESS_TOKEN); await kc.delete(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN); await kc.delete(KEYCHAIN_ACCOUNTS.SESSION_META); } /** * Decide whether the stored session is valid / should refresh / requires re-auth, given the * clock. Loads metadata and delegates to the pure decideTokenRefresh. No session → 'reauth'. */ async function decide({ now, skewMs } = {}) { const session = await loadSession(); if (!session) return 'reauth'; return decideTokenRefresh({ expiresAt: session.expiresAt, now, skewMs, refreshExpiresAt: session.refreshExpiresAt ?? undefined, }); } /** Store the Phase 2 per-session loopback bearer token. */ async function storeLoopbackToken(token) { await kc.set(KEYCHAIN_ACCOUNTS.LOOPBACK_TOKEN, requireSecret(token, 'loopbackToken')); } /** Read the Phase 2 per-session loopback bearer token, or null if absent. */ async function getLoopbackToken() { const t = await kc.get(KEYCHAIN_ACCOUNTS.LOOPBACK_TOKEN); return typeof t === 'string' && t.length > 0 ? t : null; } /** * Rotate the loopback token (call at each companion start): overwrite the prior per-session * token with a fresh one. Identical to store, named for intent + the rotation contract. */ async function rotateLoopbackToken(token) { await kc.set(KEYCHAIN_ACCOUNTS.LOOPBACK_TOKEN, requireSecret(token, 'loopbackToken')); } /** Remove the loopback token (companion shutdown). Idempotent. */ async function clearLoopbackToken() { await kc.delete(KEYCHAIN_ACCOUNTS.LOOPBACK_TOKEN); } return { storeSession, loadSession, clearSession, decide, updateAccessToken, storeLoopbackToken, getLoopbackToken, rotateLoopbackToken, clearLoopbackToken, }; }