refresh-token-store.mjs
317 lines 11.6 KB
Raw
sha256:fbe982a22c05c6fe2e93876f250deecdb43d883f648b4e1810c7546caa9f17db docs: activate KNOWTATION- board identity and preserve livi… Human minor ⚠ breaking 2 days ago
1 /**
2 * Durable refresh-token store for the hosted gateway.
3 *
4 * This is the hosted analogue of the self-hosted `hub/refresh-tokens.mjs` file store. It
5 * persists refresh-token records to a Netlify Blob in production (and a local JSON file in
6 * dev/test) and delegates ALL security logic — rotation, reuse detection, hashing, expiry —
7 * to the pure, audited `hub/lib/refresh-token-core.mjs`. The dangerous logic therefore lives
8 * in exactly one place across every deployment surface; this module is intentionally thin and
9 * only does I/O.
10 *
11 * ## Consistency model and the reuse-detection trade-off
12 *
13 * Refresh-token rotation depends on read-after-write. On Netlify (web-session cookies) the blob
14 * store is eventual only (`globalThis.__knowtation_gateway_auth_blob`); reuse detection may lag
15 * ≤60s. **MCP OAuth refresh MUST call `createGatewayRefreshStore({ consistency: 'strong' })`**
16 * so the store is file-backed on the persistent MCP host and never uses the blob path
17 * (docs/DURABLE-AGENT-AUTH-ROADMAP.md Phase A).
18 *
19 * ## Storage shape (matches the self-hosted file store)
20 * { "tokens": { "<id>": { sub, family_id, token_hash, created_at, expires_at,
21 * family_expires_at, rotated_to, used_at, revoked, meta } } }
22 * Only non-secret values are persisted; the raw token secret is returned to the caller exactly
23 * once and never stored.
24 */
25
26 import fs from 'fs/promises';
27 import path from 'path';
28 import { fileURLToPath } from 'url';
29 import crypto from 'node:crypto';
30 import {
31 issueToken,
32 rotateToken,
33 revokeToken,
34 revokeAllForSub,
35 pruneExpired,
36 parseToken,
37 hashSecret,
38 } from '../lib/refresh-token-core.mjs';
39
40 const BLOB_KEY = 'refresh-tokens-v1';
41
42 // Safe when bundled (e.g. Netlify Functions CJS) where import.meta may be undefined.
43 let projectRoot;
44 try {
45 const __dirname = path.dirname(fileURLToPath(import.meta.url));
46 projectRoot = path.resolve(__dirname, '..', '..');
47 } catch (_) {
48 projectRoot = process.cwd();
49 }
50
51 /**
52 * Local file fallback location (dev / self-run gateway / tests). KNOWTATION_GATEWAY_DATA_DIR
53 * lets tests point this at a temp dir without touching the repo's data/ folder.
54 */
55 function refreshFilePath() {
56 const dataDir = process.env.KNOWTATION_GATEWAY_DATA_DIR || path.join(projectRoot, 'data');
57 return path.join(dataDir, 'hosted_refresh_tokens.json');
58 }
59
60 /**
61 * The (eventual-consistency) Netlify Blob store, set per-invocation by the Netlify function
62 * wrapper. Absent outside Netlify (dev/test), in which case we fall back to a JSON file.
63 * @returns {{ get: Function, setJSON: Function } | undefined}
64 */
65 function getBlobStore() {
66 return globalThis.__knowtation_gateway_auth_blob;
67 }
68
69 /**
70 * Coerce arbitrary persisted JSON into a clean records map, dropping anything that is not a
71 * well-formed record. A damaged/foreign payload thus degrades to "no sessions" (fail-closed:
72 * users re-authenticate) rather than throwing on every refresh.
73 * @param {unknown} raw
74 * @returns {Record<string, object>}
75 */
76 function normalizeRecords(raw) {
77 const tokens = raw && typeof raw === 'object' && raw.tokens && typeof raw.tokens === 'object' ? raw.tokens : {};
78 const out = {};
79 for (const [id, rec] of Object.entries(tokens)) {
80 if (typeof id === 'string' && rec && typeof rec === 'object' && typeof rec.token_hash === 'string') {
81 out[id] = rec;
82 }
83 }
84 return out;
85 }
86
87 async function readFromBlob() {
88 const store = getBlobStore();
89 try {
90 const raw = await store.get(BLOB_KEY, { type: 'json' });
91 return normalizeRecords(raw);
92 } catch (err) {
93 const msg = err && err.message ? err.message : String(err);
94 throw new Error(`gateway-auth blob get ${BLOB_KEY} failed: ${msg}`);
95 }
96 }
97
98 async function writeToBlob(records) {
99 const store = getBlobStore();
100 try {
101 await store.setJSON(BLOB_KEY, { tokens: records || {} });
102 } catch (err) {
103 const msg = err && err.message ? err.message : String(err);
104 const n = records && typeof records === 'object' ? Object.keys(records).length : 0;
105 throw new Error(`gateway-auth blob setJSON ${BLOB_KEY} failed (n=${n}): ${msg}`);
106 }
107 }
108
109 async function readFromFile() {
110 try {
111 const raw = await fs.readFile(refreshFilePath(), 'utf8');
112 return normalizeRecords(JSON.parse(raw));
113 } catch (e) {
114 if (e && e.code === 'ENOENT') return {};
115 // Unreadable/corrupt file: fail-closed to an empty store rather than crashing the gateway.
116 return {};
117 }
118 }
119
120 async function writeToFile(records) {
121 const filePath = refreshFilePath();
122 await fs.mkdir(path.dirname(filePath), { recursive: true });
123 // Atomic write: temp file + rename, so a crash mid-write cannot strand half-written JSON.
124 const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
125 await fs.writeFile(tmpPath, JSON.stringify({ tokens: records || {} }, null, 2), { encoding: 'utf8', mode: 0o600 });
126 await fs.rename(tmpPath, filePath);
127 }
128
129 /**
130 * Load the current records map from the active backend (blob in prod, file otherwise).
131 * @returns {Promise<Record<string, object>>}
132 */
133 export async function loadRefreshRecords() {
134 if (getBlobStore()) return readFromBlob();
135 return readFromFile();
136 }
137
138 /**
139 * Persist the records map to the active backend.
140 * @param {Record<string, object>} records
141 * @returns {Promise<void>}
142 */
143 export async function saveRefreshRecords(records) {
144 if (getBlobStore()) {
145 await writeToBlob(records);
146 return;
147 }
148 await writeToFile(records);
149 }
150
151 /**
152 * Issue a new refresh token for a user at login and persist it.
153 * @param {string} sub - e.g. "google:123"
154 * @param {{ now?: number, tokenTtlMs?: number, familyTtlMs?: number, meta?: object }} [opts]
155 * @returns {Promise<{ token: string, id: string, familyId: string }>}
156 */
157 export async function issueRefreshToken(sub, opts = {}) {
158 const records = await loadRefreshRecords();
159 const result = issueToken(records, { sub, ...opts });
160 await saveRefreshRecords(result.records);
161 return { token: result.token, id: result.id, familyId: result.familyId };
162 }
163
164 /**
165 * Validate + rotate a presented refresh token, persisting the new state. On reuse or
166 * revocation the whole family is burned and persisted before the failure is returned.
167 * @param {string} token
168 * @param {{ now?: number, tokenTtlMs?: number, meta?: object }} [opts]
169 * @returns {Promise<{ ok: true, token: string, sub: string } | { ok: false, reason: string, sub: string|null }>}
170 */
171 export async function rotateRefreshToken(token, opts = {}) {
172 const records = await loadRefreshRecords();
173 const result = rotateToken(records, token, opts);
174 // Persist whenever the records changed (success rotates; reuse/revoked burns the family).
175 await saveRefreshRecords(result.records);
176 if (result.ok) return { ok: true, token: result.token, sub: result.sub };
177 return { ok: false, reason: result.reason, sub: result.sub };
178 }
179
180 /**
181 * Revoke a single refresh token (ordinary logout).
182 * @param {string} token
183 * @returns {Promise<{ revoked: boolean, sub: string|null }>}
184 */
185 export async function revokeRefreshToken(token) {
186 const records = await loadRefreshRecords();
187 const result = revokeToken(records, token);
188 if (result.revoked) await saveRefreshRecords(result.records);
189 return { revoked: result.revoked, sub: result.sub };
190 }
191
192 /**
193 * Revoke every refresh token for a user ("sign out all sessions" / compromise response).
194 * @param {string} sub
195 * @returns {Promise<{ count: number }>}
196 */
197 export async function revokeAllRefreshTokensForSub(sub) {
198 const records = await loadRefreshRecords();
199 const result = revokeAllForSub(records, sub);
200 if (result.count > 0) await saveRefreshRecords(result.records);
201 return { count: result.count };
202 }
203
204 /**
205 * Remove dead/stale records. Safe to call opportunistically (e.g. at login).
206 * @param {{ now?: number, graceMs?: number }} [opts]
207 * @returns {Promise<{ removed: number }>}
208 */
209 export async function pruneRefreshTokens(opts = {}) {
210 const records = await loadRefreshRecords();
211 const result = pruneExpired(records, opts);
212 if (result.removed > 0) await saveRefreshRecords(result.records);
213 return { removed: result.removed };
214 }
215
216 /**
217 * Build the `{ issue, rotate, revoke, peek }` store object auth handlers expect, bound
218 * to this gateway backend. All methods are async; the handlers `await` them.
219 *
220 * @param {{ consistency?: 'strong' | 'eventual' }} [opts]
221 * - `strong` — **always** use the local JSON file backend (read-after-write). Required for
222 * MCP OAuth refresh on the persistent MCP host. Prohibits the Netlify blob path even if
223 * `globalThis.__knowtation_gateway_auth_blob` is set (blob is eventual; ≤60s reuse lag),
224 * **except** on AWS Lambda (`AWS_LAMBDA_FUNCTION_NAME`) where the FS is read-only — then
225 * the provisioned blob must be used (Netlify Functions may omit `NETLIFY=true` at runtime).
226 * - omit / `eventual` — blob when provisioned (Netlify web refresh cookies), else file.
227 * @returns {{
228 * issue: Function,
229 * rotate: Function,
230 * revoke: Function,
231 * peek: Function,
232 * consistency: 'strong' | 'eventual',
233 * }}
234 */
235 export function createGatewayRefreshStore(opts = {}) {
236 const consistency = opts.consistency === 'strong' ? 'strong' : 'eventual';
237 const forceFile = consistency === 'strong';
238
239 function preferBlob() {
240 if (!getBlobStore()) return false;
241 if (!forceFile) return true;
242 // Hotfix: strong was chosen because NETLIFY was unset at module init, but we are on
243 // Lambda with a live auth blob — never fall back to mkdir /var/task/data.
244 return Boolean(process.env.AWS_LAMBDA_FUNCTION_NAME);
245 }
246
247 async function load() {
248 if (preferBlob()) return readFromBlob();
249 return readFromFile();
250 }
251
252 async function save(records) {
253 if (preferBlob()) {
254 await writeToBlob(records);
255 return;
256 }
257 await writeToFile(records);
258 }
259
260 return {
261 consistency,
262 issue: async (sub, issueOpts = {}) => {
263 const records = await load();
264 const result = issueToken(records, { sub, ...issueOpts });
265 await save(result.records);
266 return { token: result.token, id: result.id, familyId: result.familyId };
267 },
268 rotate: async (token, rotateOpts = {}) => {
269 const records = await load();
270 const result = rotateToken(records, token, rotateOpts);
271 await save(result.records);
272 if (result.ok) {
273 return { ok: true, token: result.token, sub: result.sub, meta: result.meta || {} };
274 }
275 return { ok: false, reason: result.reason, sub: result.sub };
276 },
277 revoke: async (token) => {
278 const records = await load();
279 const result = revokeToken(records, token);
280 if (result.revoked) await save(result.records);
281 return { revoked: result.revoked, sub: result.sub };
282 },
283 /**
284 * Validate a presented token's secret and return identity + meta without rotating.
285 * Used by MCP OAuth to enforce client_id binding before rotate.
286 * @param {string} token
287 * @returns {Promise<null | {
288 * sub: string,
289 * meta: object,
290 * revoked: boolean,
291 * consumed: boolean,
292 * expires_at: number,
293 * family_expires_at: number,
294 * }>}
295 */
296 peek: async (token) => {
297 const records = await load();
298 const parsed = parseToken(token);
299 if (!parsed) return null;
300 const rec = records[parsed.id];
301 if (!rec || typeof rec.token_hash !== 'string') return null;
302 const expected = Buffer.from(rec.token_hash);
303 const actual = Buffer.from(hashSecret(parsed.secret));
304 if (expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual)) {
305 return null;
306 }
307 return {
308 sub: rec.sub,
309 meta: rec.meta && typeof rec.meta === 'object' ? rec.meta : {},
310 revoked: Boolean(rec.revoked),
311 consumed: Boolean(rec.rotated_to),
312 expires_at: rec.expires_at,
313 family_expires_at: rec.family_expires_at,
314 };
315 },
316 };
317 }
File History 1 commit
sha256:fbe982a22c05c6fe2e93876f250deecdb43d883f648b4e1810c7546caa9f17db docs: activate KNOWTATION- board identity and preserve livi… Human minor 2 days ago