companion-token-custody.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * Companion token CUSTODY — pure lifecycle logic over an INJECTED keychain adapter. |
| 3 | * |
| 4 | * Phase 3 of the Companion App build plan (feat/companion-app). |
| 5 | * See docs/COMPANION-APP-PHASE-3-OAUTH-PKCE.md for the accepted design and the custody/rotation |
| 6 | * rules this module encodes. |
| 7 | * |
| 8 | * WHAT THIS MODULE IS |
| 9 | * The decision + orchestration layer for what the companion stores at rest and when it rotates |
| 10 | * or clears it. It governs three secrets: |
| 11 | * 1. the OAuth access token (the JWT the companion presents to the hosted gateway/canister), |
| 12 | * 2. the OAuth refresh token (opaque; used to mint a new JWT without a fresh browser login), |
| 13 | * 3. the Phase 2 per-session LOOPBACK bearer token (custody of which this phase defines — |
| 14 | * the loopback inference endpoint's credential, generated by the companion at start). |
| 15 | * It also stores non-secret session metadata (expiry, refresh ceiling, scope, issuer) needed to |
| 16 | * drive the refresh decision (decideTokenRefresh, from companion-oauth-pkce.mjs). |
| 17 | * |
| 18 | * WHAT THIS MODULE IS NOT |
| 19 | * - It performs NO real OS-keychain I/O. Every read/write/delete is delegated to an INJECTED |
| 20 | * adapter (`{ get, set, delete }`). Phase 5 supplies the real adapter backed by macOS |
| 21 | * Keychain / Windows DPAPI / Linux libsecret; tests supply an in-memory fake. This keeps the |
| 22 | * custody LOGIC pure and exhaustively testable, and keeps the gate's "no real keychain in |
| 23 | * this phase" line intact. |
| 24 | * - It NEVER writes a secret to a plaintext file, and NEVER logs a token, JWT, refresh token, |
| 25 | * loopback token, or any error message containing one. Thrown errors carry fixed messages. |
| 26 | * |
| 27 | * THREAT MODEL (custody-specific; full model in the design doc): |
| 28 | * - JWT/refresh theft at rest → only the OS keychain is used (never a dotfile / env / log). |
| 29 | * The adapter is the sole persistence path; this module hands the adapter only the named |
| 30 | * secret, never a derived plaintext copy. |
| 31 | * - Refresh-token replay → on a detected reuse/`invalid_grant` the caller invokes clearSession() |
| 32 | * which removes BOTH tokens (the family is dead; force a fresh login). |
| 33 | * - Loopback-token leakage → the loopback token is per-session and rotated at each companion |
| 34 | * start (rotateLoopbackToken); it is stored under its own account, separate from the JWT, so |
| 35 | * a compromise of one is not automatically a compromise of the other. |
| 36 | * |
| 37 | * DESIGN CONSTRAINTS: |
| 38 | * - Adapter calls are always awaited, so a synchronous OR Promise-returning adapter both work |
| 39 | * (awaiting a non-Promise is a no-op) — one code path drives tests and every real OS backend. |
| 40 | * - FAIL-CLOSED: a partial/absent session loads as null (→ caller treats as 'reauth'); invalid |
| 41 | * inputs to store* throw a fixed-message error rather than persisting something unusable. |
| 42 | */ |
| 43 | |
| 44 | import { decideTokenRefresh } from './companion-oauth-pkce.mjs'; |
| 45 | |
| 46 | /** |
| 47 | * Keychain account names (the "service"/"account" keys under which secrets are stored). |
| 48 | * Stable, namespaced, and non-secret. Phase 5's real adapter maps these to Keychain/DPAPI/ |
| 49 | * libsecret entries. |
| 50 | * @readonly |
| 51 | */ |
| 52 | export const KEYCHAIN_ACCOUNTS = Object.freeze({ |
| 53 | ACCESS_TOKEN: 'knowtation.companion.accessToken', |
| 54 | REFRESH_TOKEN: 'knowtation.companion.refreshToken', |
| 55 | SESSION_META: 'knowtation.companion.sessionMeta', |
| 56 | LOOPBACK_TOKEN: 'knowtation.companion.loopbackToken', |
| 57 | }); |
| 58 | |
| 59 | /** Upper bound on any single stored secret (defense against an unbounded write). */ |
| 60 | const MAX_SECRET_LEN = 8192; |
| 61 | /** Upper bound on the serialized metadata blob. */ |
| 62 | const MAX_META_LEN = 8192; |
| 63 | |
| 64 | /** |
| 65 | * Validate that an injected adapter exposes the required interface. Throws a fixed-message error |
| 66 | * (carrying no secret) if not. |
| 67 | * @param {unknown} keychain |
| 68 | * @returns {{ get: Function, set: Function, delete: Function }} |
| 69 | */ |
| 70 | function requireAdapter(keychain) { |
| 71 | if ( |
| 72 | !keychain || |
| 73 | typeof keychain !== 'object' || |
| 74 | typeof (/** @type {any} */ (keychain).get) !== 'function' || |
| 75 | typeof (/** @type {any} */ (keychain).set) !== 'function' || |
| 76 | typeof (/** @type {any} */ (keychain).delete) !== 'function' |
| 77 | ) { |
| 78 | throw new TypeError('createTokenCustody: keychain adapter must implement { get, set, delete }'); |
| 79 | } |
| 80 | return /** @type {{ get: Function, set: Function, delete: Function }} */ (keychain); |
| 81 | } |
| 82 | |
| 83 | /** |
| 84 | * Validate a secret string before it is handed to the adapter. |
| 85 | * @param {unknown} value |
| 86 | * @param {string} label - used only in the fixed error message (never the value) |
| 87 | * @returns {string} |
| 88 | */ |
| 89 | function requireSecret(value, label) { |
| 90 | if (typeof value !== 'string' || value.length === 0 || value.length > MAX_SECRET_LEN) { |
| 91 | throw new TypeError(`token-custody: ${label} must be a non-empty string within length bounds`); |
| 92 | } |
| 93 | return value; |
| 94 | } |
| 95 | |
| 96 | /** |
| 97 | * Build a non-secret session-metadata record from a validated token response (the result of |
| 98 | * companion-oauth-pkce.validateTokenResponse) plus the current clock. PURE. |
| 99 | * |
| 100 | * Computes: |
| 101 | * - expiresAt = now + expiresIn*1000 (epoch-ms), |
| 102 | * - refreshExpiresAt = now + refreshTtlMs when a refresh token exists and a TTL is supplied |
| 103 | * (the absolute ceiling after which a refresh is no longer attempted), |
| 104 | * - scope, tokenType, and optional issuer for audit/refresh decisions. |
| 105 | * |
| 106 | * @param {{ expiresIn: number, refreshToken: string | null, scope: string | null, tokenType: string }} tokenResponse |
| 107 | * @param {{ now: number, refreshTtlMs?: number, issuer?: string }} ctx |
| 108 | * @returns {{ expiresAt: number, refreshExpiresAt: number | null, scope: string | null, tokenType: string, issuer: string | null, storedAt: number }} |
| 109 | */ |
| 110 | export function buildSessionMeta(tokenResponse, ctx) { |
| 111 | const tr = tokenResponse ?? {}; |
| 112 | const now = ctx?.now; |
| 113 | if (typeof now !== 'number' || !Number.isFinite(now)) { |
| 114 | throw new TypeError('buildSessionMeta: now must be a finite number'); |
| 115 | } |
| 116 | if (typeof tr.expiresIn !== 'number' || !Number.isInteger(tr.expiresIn) || tr.expiresIn <= 0) { |
| 117 | throw new TypeError('buildSessionMeta: tokenResponse.expiresIn must be a positive integer'); |
| 118 | } |
| 119 | const hasRefresh = typeof tr.refreshToken === 'string' && tr.refreshToken.length > 0; |
| 120 | const refreshTtlMs = ctx?.refreshTtlMs; |
| 121 | const refreshExpiresAt = hasRefresh && typeof refreshTtlMs === 'number' && Number.isFinite(refreshTtlMs) && refreshTtlMs > 0 |
| 122 | ? now + refreshTtlMs |
| 123 | : null; |
| 124 | return { |
| 125 | expiresAt: now + tr.expiresIn * 1000, |
| 126 | refreshExpiresAt, |
| 127 | scope: typeof tr.scope === 'string' ? tr.scope : null, |
| 128 | tokenType: typeof tr.tokenType === 'string' ? tr.tokenType : 'Bearer', |
| 129 | issuer: typeof ctx?.issuer === 'string' && ctx.issuer.length > 0 ? ctx.issuer : null, |
| 130 | storedAt: now, |
| 131 | }; |
| 132 | } |
| 133 | |
| 134 | /** |
| 135 | * Create a token-custody handle bound to an injected keychain adapter. |
| 136 | * |
| 137 | * @param {{ get: (account: string) => (string|null|Promise<string|null>), |
| 138 | * set: (account: string, secret: string) => (void|Promise<void>), |
| 139 | * delete: (account: string) => (void|Promise<void>) }} keychain |
| 140 | * @returns {{ |
| 141 | * storeSession: (args: { accessToken: string, refreshToken?: string|null, meta: object }) => Promise<void>, |
| 142 | * loadSession: () => Promise<null | { accessToken: string, refreshToken: string|null, expiresAt: number, refreshExpiresAt: number|null, scope: string|null, tokenType: string, issuer: string|null }>, |
| 143 | * clearSession: () => Promise<void>, |
| 144 | * decide: (args: { now: number, skewMs?: number }) => Promise<'valid'|'refresh'|'reauth'>, |
| 145 | * updateAccessToken: (args: { accessToken: string, meta: object, refreshToken?: string|null }) => Promise<void>, |
| 146 | * storeLoopbackToken: (token: string) => Promise<void>, |
| 147 | * getLoopbackToken: () => Promise<string|null>, |
| 148 | * rotateLoopbackToken: (token: string) => Promise<void>, |
| 149 | * clearLoopbackToken: () => Promise<void>, |
| 150 | * }} |
| 151 | */ |
| 152 | export function createTokenCustody(keychain) { |
| 153 | const kc = requireAdapter(keychain); |
| 154 | |
| 155 | /** @param {object} meta */ |
| 156 | function serializeMeta(meta) { |
| 157 | if (!meta || typeof meta !== 'object') { |
| 158 | throw new TypeError('token-custody: session meta must be an object'); |
| 159 | } |
| 160 | if (typeof meta.expiresAt !== 'number' || !Number.isFinite(meta.expiresAt)) { |
| 161 | throw new TypeError('token-custody: session meta.expiresAt must be a finite number'); |
| 162 | } |
| 163 | const json = JSON.stringify(meta); |
| 164 | if (json.length > MAX_META_LEN) { |
| 165 | throw new TypeError('token-custody: session meta exceeds length bounds'); |
| 166 | } |
| 167 | return json; |
| 168 | } |
| 169 | |
| 170 | /** |
| 171 | * Persist a freshly-acquired session: the JWT, the optional refresh token, and the metadata. |
| 172 | * Overwrites any prior session for these accounts. |
| 173 | */ |
| 174 | async function storeSession({ accessToken, refreshToken = null, meta } = {}) { |
| 175 | const at = requireSecret(accessToken, 'accessToken'); |
| 176 | const metaJson = serializeMeta(meta); |
| 177 | await kc.set(KEYCHAIN_ACCOUNTS.ACCESS_TOKEN, at); |
| 178 | if (refreshToken === null || refreshToken === undefined) { |
| 179 | // No refresh token in this grant — ensure no stale one survives. |
| 180 | await kc.delete(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN); |
| 181 | } else { |
| 182 | await kc.set(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN, requireSecret(refreshToken, 'refreshToken')); |
| 183 | } |
| 184 | await kc.set(KEYCHAIN_ACCOUNTS.SESSION_META, metaJson); |
| 185 | } |
| 186 | |
| 187 | /** |
| 188 | * Replace the access token (and metadata) after a successful refresh, rotating the refresh |
| 189 | * token when the server returns a new one (rotation). The prior refresh token is overwritten. |
| 190 | */ |
| 191 | async function updateAccessToken({ accessToken, meta, refreshToken } = {}) { |
| 192 | const at = requireSecret(accessToken, 'accessToken'); |
| 193 | const metaJson = serializeMeta(meta); |
| 194 | await kc.set(KEYCHAIN_ACCOUNTS.ACCESS_TOKEN, at); |
| 195 | if (refreshToken !== undefined) { |
| 196 | if (refreshToken === null) { |
| 197 | await kc.delete(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN); |
| 198 | } else { |
| 199 | await kc.set(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN, requireSecret(refreshToken, 'refreshToken')); |
| 200 | } |
| 201 | } |
| 202 | await kc.set(KEYCHAIN_ACCOUNTS.SESSION_META, metaJson); |
| 203 | } |
| 204 | |
| 205 | /** |
| 206 | * Load the current session. Returns null (fail-closed) if the access token or metadata is |
| 207 | * missing or unparsable — the caller then treats it as 'reauth'. |
| 208 | */ |
| 209 | async function loadSession() { |
| 210 | const accessToken = await kc.get(KEYCHAIN_ACCOUNTS.ACCESS_TOKEN); |
| 211 | if (typeof accessToken !== 'string' || accessToken.length === 0) return null; |
| 212 | const metaRaw = await kc.get(KEYCHAIN_ACCOUNTS.SESSION_META); |
| 213 | if (typeof metaRaw !== 'string' || metaRaw.length === 0 || metaRaw.length > MAX_META_LEN) return null; |
| 214 | let meta; |
| 215 | try { |
| 216 | meta = JSON.parse(metaRaw); |
| 217 | } catch { |
| 218 | return null; |
| 219 | } |
| 220 | if (!meta || typeof meta !== 'object' || typeof meta.expiresAt !== 'number' || !Number.isFinite(meta.expiresAt)) { |
| 221 | return null; |
| 222 | } |
| 223 | const refreshTokenRaw = await kc.get(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN); |
| 224 | const refreshToken = typeof refreshTokenRaw === 'string' && refreshTokenRaw.length > 0 ? refreshTokenRaw : null; |
| 225 | return { |
| 226 | accessToken, |
| 227 | refreshToken, |
| 228 | expiresAt: meta.expiresAt, |
| 229 | refreshExpiresAt: typeof meta.refreshExpiresAt === 'number' && Number.isFinite(meta.refreshExpiresAt) ? meta.refreshExpiresAt : null, |
| 230 | scope: typeof meta.scope === 'string' ? meta.scope : null, |
| 231 | tokenType: typeof meta.tokenType === 'string' ? meta.tokenType : 'Bearer', |
| 232 | issuer: typeof meta.issuer === 'string' ? meta.issuer : null, |
| 233 | }; |
| 234 | } |
| 235 | |
| 236 | /** |
| 237 | * Remove the OAuth session secrets (access + refresh + metadata). Idempotent. Used on logout |
| 238 | * and on refresh-reuse / invalid_grant (family is dead → force a fresh login). Does NOT touch |
| 239 | * the loopback token, which has an independent lifecycle. |
| 240 | */ |
| 241 | async function clearSession() { |
| 242 | await kc.delete(KEYCHAIN_ACCOUNTS.ACCESS_TOKEN); |
| 243 | await kc.delete(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN); |
| 244 | await kc.delete(KEYCHAIN_ACCOUNTS.SESSION_META); |
| 245 | } |
| 246 | |
| 247 | /** |
| 248 | * Decide whether the stored session is valid / should refresh / requires re-auth, given the |
| 249 | * clock. Loads metadata and delegates to the pure decideTokenRefresh. No session → 'reauth'. |
| 250 | */ |
| 251 | async function decide({ now, skewMs } = {}) { |
| 252 | const session = await loadSession(); |
| 253 | if (!session) return 'reauth'; |
| 254 | return decideTokenRefresh({ |
| 255 | expiresAt: session.expiresAt, |
| 256 | now, |
| 257 | skewMs, |
| 258 | refreshExpiresAt: session.refreshExpiresAt ?? undefined, |
| 259 | }); |
| 260 | } |
| 261 | |
| 262 | /** Store the Phase 2 per-session loopback bearer token. */ |
| 263 | async function storeLoopbackToken(token) { |
| 264 | await kc.set(KEYCHAIN_ACCOUNTS.LOOPBACK_TOKEN, requireSecret(token, 'loopbackToken')); |
| 265 | } |
| 266 | |
| 267 | /** Read the Phase 2 per-session loopback bearer token, or null if absent. */ |
| 268 | async function getLoopbackToken() { |
| 269 | const t = await kc.get(KEYCHAIN_ACCOUNTS.LOOPBACK_TOKEN); |
| 270 | return typeof t === 'string' && t.length > 0 ? t : null; |
| 271 | } |
| 272 | |
| 273 | /** |
| 274 | * Rotate the loopback token (call at each companion start): overwrite the prior per-session |
| 275 | * token with a fresh one. Identical to store, named for intent + the rotation contract. |
| 276 | */ |
| 277 | async function rotateLoopbackToken(token) { |
| 278 | await kc.set(KEYCHAIN_ACCOUNTS.LOOPBACK_TOKEN, requireSecret(token, 'loopbackToken')); |
| 279 | } |
| 280 | |
| 281 | /** Remove the loopback token (companion shutdown). Idempotent. */ |
| 282 | async function clearLoopbackToken() { |
| 283 | await kc.delete(KEYCHAIN_ACCOUNTS.LOOPBACK_TOKEN); |
| 284 | } |
| 285 | |
| 286 | return { |
| 287 | storeSession, |
| 288 | loadSession, |
| 289 | clearSession, |
| 290 | decide, |
| 291 | updateAccessToken, |
| 292 | storeLoopbackToken, |
| 293 | getLoopbackToken, |
| 294 | rotateLoopbackToken, |
| 295 | clearLoopbackToken, |
| 296 | }; |
| 297 | } |
File History
2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠
1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6
docs: accept Calendar Events v0 spec with Phase 0 security …
Human
2 days ago