refresh-token-store.mjs
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