refresh-token-store.mjs
296 lines 10.7 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 10 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 const raw = await store.get(BLOB_KEY, { type: 'json' });
90 return normalizeRecords(raw);
91 }
92
93 async function writeToBlob(records) {
94 const store = getBlobStore();
95 await store.setJSON(BLOB_KEY, { tokens: records || {} });
96 }
97
98 async function readFromFile() {
99 try {
100 const raw = await fs.readFile(refreshFilePath(), 'utf8');
101 return normalizeRecords(JSON.parse(raw));
102 } catch (e) {
103 if (e && e.code === 'ENOENT') return {};
104 // Unreadable/corrupt file: fail-closed to an empty store rather than crashing the gateway.
105 return {};
106 }
107 }
108
109 async function writeToFile(records) {
110 const filePath = refreshFilePath();
111 await fs.mkdir(path.dirname(filePath), { recursive: true });
112 // Atomic write: temp file + rename, so a crash mid-write cannot strand half-written JSON.
113 const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
114 await fs.writeFile(tmpPath, JSON.stringify({ tokens: records || {} }, null, 2), { encoding: 'utf8', mode: 0o600 });
115 await fs.rename(tmpPath, filePath);
116 }
117
118 /**
119 * Load the current records map from the active backend (blob in prod, file otherwise).
120 * @returns {Promise<Record<string, object>>}
121 */
122 export async function loadRefreshRecords() {
123 if (getBlobStore()) return readFromBlob();
124 return readFromFile();
125 }
126
127 /**
128 * Persist the records map to the active backend.
129 * @param {Record<string, object>} records
130 * @returns {Promise<void>}
131 */
132 export async function saveRefreshRecords(records) {
133 if (getBlobStore()) {
134 await writeToBlob(records);
135 return;
136 }
137 await writeToFile(records);
138 }
139
140 /**
141 * Issue a new refresh token for a user at login and persist it.
142 * @param {string} sub - e.g. "google:123"
143 * @param {{ now?: number, tokenTtlMs?: number, familyTtlMs?: number, meta?: object }} [opts]
144 * @returns {Promise<{ token: string, id: string, familyId: string }>}
145 */
146 export async function issueRefreshToken(sub, opts = {}) {
147 const records = await loadRefreshRecords();
148 const result = issueToken(records, { sub, ...opts });
149 await saveRefreshRecords(result.records);
150 return { token: result.token, id: result.id, familyId: result.familyId };
151 }
152
153 /**
154 * Validate + rotate a presented refresh token, persisting the new state. On reuse or
155 * revocation the whole family is burned and persisted before the failure is returned.
156 * @param {string} token
157 * @param {{ now?: number, tokenTtlMs?: number, meta?: object }} [opts]
158 * @returns {Promise<{ ok: true, token: string, sub: string } | { ok: false, reason: string, sub: string|null }>}
159 */
160 export async function rotateRefreshToken(token, opts = {}) {
161 const records = await loadRefreshRecords();
162 const result = rotateToken(records, token, opts);
163 // Persist whenever the records changed (success rotates; reuse/revoked burns the family).
164 await saveRefreshRecords(result.records);
165 if (result.ok) return { ok: true, token: result.token, sub: result.sub };
166 return { ok: false, reason: result.reason, sub: result.sub };
167 }
168
169 /**
170 * Revoke a single refresh token (ordinary logout).
171 * @param {string} token
172 * @returns {Promise<{ revoked: boolean, sub: string|null }>}
173 */
174 export async function revokeRefreshToken(token) {
175 const records = await loadRefreshRecords();
176 const result = revokeToken(records, token);
177 if (result.revoked) await saveRefreshRecords(result.records);
178 return { revoked: result.revoked, sub: result.sub };
179 }
180
181 /**
182 * Revoke every refresh token for a user ("sign out all sessions" / compromise response).
183 * @param {string} sub
184 * @returns {Promise<{ count: number }>}
185 */
186 export async function revokeAllRefreshTokensForSub(sub) {
187 const records = await loadRefreshRecords();
188 const result = revokeAllForSub(records, sub);
189 if (result.count > 0) await saveRefreshRecords(result.records);
190 return { count: result.count };
191 }
192
193 /**
194 * Remove dead/stale records. Safe to call opportunistically (e.g. at login).
195 * @param {{ now?: number, graceMs?: number }} [opts]
196 * @returns {Promise<{ removed: number }>}
197 */
198 export async function pruneRefreshTokens(opts = {}) {
199 const records = await loadRefreshRecords();
200 const result = pruneExpired(records, opts);
201 if (result.removed > 0) await saveRefreshRecords(result.records);
202 return { removed: result.removed };
203 }
204
205 /**
206 * Build the `{ issue, rotate, revoke, peek }` store object auth handlers expect, bound
207 * to this gateway backend. All methods are async; the handlers `await` them.
208 *
209 * @param {{ consistency?: 'strong' | 'eventual' }} [opts]
210 * - `strong` — **always** use the local JSON file backend (read-after-write). Required for
211 * MCP OAuth refresh on the persistent MCP host. Prohibits the Netlify blob path even if
212 * `globalThis.__knowtation_gateway_auth_blob` is set (blob is eventual; ≤60s reuse lag).
213 * - omit / `eventual` — blob when provisioned (Netlify web refresh cookies), else file.
214 * @returns {{
215 * issue: Function,
216 * rotate: Function,
217 * revoke: Function,
218 * peek: Function,
219 * consistency: 'strong' | 'eventual',
220 * }}
221 */
222 export function createGatewayRefreshStore(opts = {}) {
223 const consistency = opts.consistency === 'strong' ? 'strong' : 'eventual';
224 const forceFile = consistency === 'strong';
225
226 async function load() {
227 if (!forceFile && getBlobStore()) return readFromBlob();
228 return readFromFile();
229 }
230
231 async function save(records) {
232 if (!forceFile && getBlobStore()) {
233 await writeToBlob(records);
234 return;
235 }
236 await writeToFile(records);
237 }
238
239 return {
240 consistency,
241 issue: async (sub, issueOpts = {}) => {
242 const records = await load();
243 const result = issueToken(records, { sub, ...issueOpts });
244 await save(result.records);
245 return { token: result.token, id: result.id, familyId: result.familyId };
246 },
247 rotate: async (token, rotateOpts = {}) => {
248 const records = await load();
249 const result = rotateToken(records, token, rotateOpts);
250 await save(result.records);
251 if (result.ok) {
252 return { ok: true, token: result.token, sub: result.sub, meta: result.meta || {} };
253 }
254 return { ok: false, reason: result.reason, sub: result.sub };
255 },
256 revoke: async (token) => {
257 const records = await load();
258 const result = revokeToken(records, token);
259 if (result.revoked) await save(result.records);
260 return { revoked: result.revoked, sub: result.sub };
261 },
262 /**
263 * Validate a presented token's secret and return identity + meta without rotating.
264 * Used by MCP OAuth to enforce client_id binding before rotate.
265 * @param {string} token
266 * @returns {Promise<null | {
267 * sub: string,
268 * meta: object,
269 * revoked: boolean,
270 * consumed: boolean,
271 * expires_at: number,
272 * family_expires_at: number,
273 * }>}
274 */
275 peek: async (token) => {
276 const records = await load();
277 const parsed = parseToken(token);
278 if (!parsed) return null;
279 const rec = records[parsed.id];
280 if (!rec || typeof rec.token_hash !== 'string') return null;
281 const expected = Buffer.from(rec.token_hash);
282 const actual = Buffer.from(hashSecret(parsed.secret));
283 if (expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual)) {
284 return null;
285 }
286 return {
287 sub: rec.sub,
288 meta: rec.meta && typeof rec.meta === 'object' ? rec.meta : {},
289 revoked: Boolean(rec.revoked),
290 consumed: Boolean(rec.rotated_to),
291 expires_at: rec.expires_at,
292 family_expires_at: rec.family_expires_at,
293 };
294 },
295 };
296 }
File History 3 commits
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 10 days ago
sha256:d8c648b20a4d53b2673c5c082ee7edfa7b2fc9b11080832da1f38807b6bf940b fix(7C-L1b): route hosted delegation proposals through cani… Human minor 30 days ago
sha256:2827ba9e7632a4b141c50caf1e8f7d77abbc3515be20e7465f2bccb0ac4edf91 fix: repair endpoint now sets has_active_subscription when … Human minor 50 days ago