companion-token-custody.mjs
300 lines 13.5 KB
Raw
sha256:bd1eb24434d281fe65142038c1bf217493421f5a86880c22dff7e6037d2d4700 feat(companion): add production macOS distribution path Human minor ⚠ breaking 25 days 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, optional issuer, and the public OAuth client identifier for
105 * audit/refresh decisions.
106 *
107 * @param {{ expiresIn: number, refreshToken: string | null, scope: string | null, tokenType: string }} tokenResponse
108 * @param {{ now: number, refreshTtlMs?: number, issuer?: string, clientId?: string }} ctx
109 * @returns {{ expiresAt: number, refreshExpiresAt: number | null, scope: string | null, tokenType: string, issuer: string | null, clientId: string | null, storedAt: number }}
110 */
111 export function buildSessionMeta(tokenResponse, ctx) {
112 const tr = tokenResponse ?? {};
113 const now = ctx?.now;
114 if (typeof now !== 'number' || !Number.isFinite(now)) {
115 throw new TypeError('buildSessionMeta: now must be a finite number');
116 }
117 if (typeof tr.expiresIn !== 'number' || !Number.isInteger(tr.expiresIn) || tr.expiresIn <= 0) {
118 throw new TypeError('buildSessionMeta: tokenResponse.expiresIn must be a positive integer');
119 }
120 const hasRefresh = typeof tr.refreshToken === 'string' && tr.refreshToken.length > 0;
121 const refreshTtlMs = ctx?.refreshTtlMs;
122 const refreshExpiresAt = hasRefresh && typeof refreshTtlMs === 'number' && Number.isFinite(refreshTtlMs) && refreshTtlMs > 0
123 ? now + refreshTtlMs
124 : null;
125 return {
126 expiresAt: now + tr.expiresIn * 1000,
127 refreshExpiresAt,
128 scope: typeof tr.scope === 'string' ? tr.scope : null,
129 tokenType: typeof tr.tokenType === 'string' ? tr.tokenType : 'Bearer',
130 issuer: typeof ctx?.issuer === 'string' && ctx.issuer.length > 0 ? ctx.issuer : null,
131 clientId: typeof ctx?.clientId === 'string' && ctx.clientId.length > 0 ? ctx.clientId : null,
132 storedAt: now,
133 };
134 }
135
136 /**
137 * Create a token-custody handle bound to an injected keychain adapter.
138 *
139 * @param {{ get: (account: string) => (string|null|Promise<string|null>),
140 * set: (account: string, secret: string) => (void|Promise<void>),
141 * delete: (account: string) => (void|Promise<void>) }} keychain
142 * @returns {{
143 * storeSession: (args: { accessToken: string, refreshToken?: string|null, meta: object }) => Promise<void>,
144 * loadSession: () => Promise<null | { accessToken: string, refreshToken: string|null, expiresAt: number, refreshExpiresAt: number|null, scope: string|null, tokenType: string, issuer: string|null, clientId: string|null }>,
145 * clearSession: () => Promise<void>,
146 * decide: (args: { now: number, skewMs?: number }) => Promise<'valid'|'refresh'|'reauth'>,
147 * updateAccessToken: (args: { accessToken: string, meta: object, refreshToken?: string|null }) => Promise<void>,
148 * storeLoopbackToken: (token: string) => Promise<void>,
149 * getLoopbackToken: () => Promise<string|null>,
150 * rotateLoopbackToken: (token: string) => Promise<void>,
151 * clearLoopbackToken: () => Promise<void>,
152 * }}
153 */
154 export function createTokenCustody(keychain) {
155 const kc = requireAdapter(keychain);
156
157 /** @param {object} meta */
158 function serializeMeta(meta) {
159 if (!meta || typeof meta !== 'object') {
160 throw new TypeError('token-custody: session meta must be an object');
161 }
162 if (typeof meta.expiresAt !== 'number' || !Number.isFinite(meta.expiresAt)) {
163 throw new TypeError('token-custody: session meta.expiresAt must be a finite number');
164 }
165 const json = JSON.stringify(meta);
166 if (json.length > MAX_META_LEN) {
167 throw new TypeError('token-custody: session meta exceeds length bounds');
168 }
169 return json;
170 }
171
172 /**
173 * Persist a freshly-acquired session: the JWT, the optional refresh token, and the metadata.
174 * Overwrites any prior session for these accounts.
175 */
176 async function storeSession({ accessToken, refreshToken = null, meta } = {}) {
177 const at = requireSecret(accessToken, 'accessToken');
178 const metaJson = serializeMeta(meta);
179 await kc.set(KEYCHAIN_ACCOUNTS.ACCESS_TOKEN, at);
180 if (refreshToken === null || refreshToken === undefined) {
181 // No refresh token in this grant — ensure no stale one survives.
182 await kc.delete(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN);
183 } else {
184 await kc.set(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN, requireSecret(refreshToken, 'refreshToken'));
185 }
186 await kc.set(KEYCHAIN_ACCOUNTS.SESSION_META, metaJson);
187 }
188
189 /**
190 * Replace the access token (and metadata) after a successful refresh, rotating the refresh
191 * token when the server returns a new one (rotation). The prior refresh token is overwritten.
192 */
193 async function updateAccessToken({ accessToken, meta, refreshToken } = {}) {
194 const at = requireSecret(accessToken, 'accessToken');
195 const metaJson = serializeMeta(meta);
196 await kc.set(KEYCHAIN_ACCOUNTS.ACCESS_TOKEN, at);
197 if (refreshToken !== undefined) {
198 if (refreshToken === null) {
199 await kc.delete(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN);
200 } else {
201 await kc.set(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN, requireSecret(refreshToken, 'refreshToken'));
202 }
203 }
204 await kc.set(KEYCHAIN_ACCOUNTS.SESSION_META, metaJson);
205 }
206
207 /**
208 * Load the current session. Returns null (fail-closed) if the access token or metadata is
209 * missing or unparsable — the caller then treats it as 'reauth'.
210 */
211 async function loadSession() {
212 const accessToken = await kc.get(KEYCHAIN_ACCOUNTS.ACCESS_TOKEN);
213 if (typeof accessToken !== 'string' || accessToken.length === 0) return null;
214 const metaRaw = await kc.get(KEYCHAIN_ACCOUNTS.SESSION_META);
215 if (typeof metaRaw !== 'string' || metaRaw.length === 0 || metaRaw.length > MAX_META_LEN) return null;
216 let meta;
217 try {
218 meta = JSON.parse(metaRaw);
219 } catch {
220 return null;
221 }
222 if (!meta || typeof meta !== 'object' || typeof meta.expiresAt !== 'number' || !Number.isFinite(meta.expiresAt)) {
223 return null;
224 }
225 const refreshTokenRaw = await kc.get(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN);
226 const refreshToken = typeof refreshTokenRaw === 'string' && refreshTokenRaw.length > 0 ? refreshTokenRaw : null;
227 return {
228 accessToken,
229 refreshToken,
230 expiresAt: meta.expiresAt,
231 refreshExpiresAt: typeof meta.refreshExpiresAt === 'number' && Number.isFinite(meta.refreshExpiresAt) ? meta.refreshExpiresAt : null,
232 scope: typeof meta.scope === 'string' ? meta.scope : null,
233 tokenType: typeof meta.tokenType === 'string' ? meta.tokenType : 'Bearer',
234 issuer: typeof meta.issuer === 'string' ? meta.issuer : null,
235 clientId: typeof meta.clientId === 'string' && meta.clientId.length > 0 ? meta.clientId : null,
236 };
237 }
238
239 /**
240 * Remove the OAuth session secrets (access + refresh + metadata). Idempotent. Used on logout
241 * and on refresh-reuse / invalid_grant (family is dead → force a fresh login). Does NOT touch
242 * the loopback token, which has an independent lifecycle.
243 */
244 async function clearSession() {
245 await kc.delete(KEYCHAIN_ACCOUNTS.ACCESS_TOKEN);
246 await kc.delete(KEYCHAIN_ACCOUNTS.REFRESH_TOKEN);
247 await kc.delete(KEYCHAIN_ACCOUNTS.SESSION_META);
248 }
249
250 /**
251 * Decide whether the stored session is valid / should refresh / requires re-auth, given the
252 * clock. Loads metadata and delegates to the pure decideTokenRefresh. No session → 'reauth'.
253 */
254 async function decide({ now, skewMs } = {}) {
255 const session = await loadSession();
256 if (!session) return 'reauth';
257 return decideTokenRefresh({
258 expiresAt: session.expiresAt,
259 now,
260 skewMs,
261 refreshExpiresAt: session.refreshExpiresAt ?? undefined,
262 });
263 }
264
265 /** Store the Phase 2 per-session loopback bearer token. */
266 async function storeLoopbackToken(token) {
267 await kc.set(KEYCHAIN_ACCOUNTS.LOOPBACK_TOKEN, requireSecret(token, 'loopbackToken'));
268 }
269
270 /** Read the Phase 2 per-session loopback bearer token, or null if absent. */
271 async function getLoopbackToken() {
272 const t = await kc.get(KEYCHAIN_ACCOUNTS.LOOPBACK_TOKEN);
273 return typeof t === 'string' && t.length > 0 ? t : null;
274 }
275
276 /**
277 * Rotate the loopback token (call at each companion start): overwrite the prior per-session
278 * token with a fresh one. Identical to store, named for intent + the rotation contract.
279 */
280 async function rotateLoopbackToken(token) {
281 await kc.set(KEYCHAIN_ACCOUNTS.LOOPBACK_TOKEN, requireSecret(token, 'loopbackToken'));
282 }
283
284 /** Remove the loopback token (companion shutdown). Idempotent. */
285 async function clearLoopbackToken() {
286 await kc.delete(KEYCHAIN_ACCOUNTS.LOOPBACK_TOKEN);
287 }
288
289 return {
290 storeSession,
291 loadSession,
292 clearSession,
293 decide,
294 updateAccessToken,
295 storeLoopbackToken,
296 getLoopbackToken,
297 rotateLoopbackToken,
298 clearLoopbackToken,
299 };
300 }
File History 1 commit
sha256:bd1eb24434d281fe65142038c1bf217493421f5a86880c22dff7e6037d2d4700 feat(companion): add production macOS distribution path Human minor ⚠ 25 days ago