refresh-token-store.mjs
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 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: when a token is rotated we mark the old one |
| 14 | * consumed, and a replay of that old token should be observed as already-consumed so reuse |
| 15 | * detection (family revocation) fires. Strong consistency would make that immediate, but it is |
| 16 | * NOT available in this deployment: the hosted gateway runs in Netlify's Lambda compatibility |
| 17 | * mode (serverless-http + connectLambda), which provisions only edge (eventual) access and omits |
| 18 | * the `uncachedEdgeURL` that strong-consistency reads require — a strong read throws |
| 19 | * BlobsConsistencyError. The function therefore provisions this store |
| 20 | * (`globalThis.__knowtation_gateway_auth_blob`) with `consistency: 'eventual'` (same as billing). |
| 21 | * |
| 22 | * Netlify propagates writes to all edges within 60s (usually sub-second), so a replayed consumed |
| 23 | * token is still detected once the write propagates; the residual exposure is a narrow window in |
| 24 | * which an ALREADY-stolen token could be rotated once before detection. The primary defenses are |
| 25 | * unchanged: the secret is a 256-bit opaque token delivered only as an HttpOnly cookie (never |
| 26 | * readable by JS), every rotation re-writes the whole record map (last-write-wins), and any |
| 27 | * detected reuse burns the entire family. If strict immediate detection is ever required, harden |
| 28 | * by moving this store to the strongly-consistent ICP canister or to Netlify's token-based API |
| 29 | * (origin) blob access. |
| 30 | * |
| 31 | * ## Storage shape (matches the self-hosted file store) |
| 32 | * { "tokens": { "<id>": { sub, family_id, token_hash, created_at, expires_at, |
| 33 | * family_expires_at, rotated_to, used_at, revoked, meta } } } |
| 34 | * Only non-secret values are persisted; the raw token secret is returned to the caller exactly |
| 35 | * once and never stored. |
| 36 | */ |
| 37 | |
| 38 | import fs from 'fs/promises'; |
| 39 | import path from 'path'; |
| 40 | import { fileURLToPath } from 'url'; |
| 41 | import { |
| 42 | issueToken, |
| 43 | rotateToken, |
| 44 | revokeToken, |
| 45 | revokeAllForSub, |
| 46 | pruneExpired, |
| 47 | } from '../lib/refresh-token-core.mjs'; |
| 48 | |
| 49 | const BLOB_KEY = 'refresh-tokens-v1'; |
| 50 | |
| 51 | // Safe when bundled (e.g. Netlify Functions CJS) where import.meta may be undefined. |
| 52 | let projectRoot; |
| 53 | try { |
| 54 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 55 | projectRoot = path.resolve(__dirname, '..', '..'); |
| 56 | } catch (_) { |
| 57 | projectRoot = process.cwd(); |
| 58 | } |
| 59 | |
| 60 | /** |
| 61 | * Local file fallback location (dev / self-run gateway / tests). KNOWTATION_GATEWAY_DATA_DIR |
| 62 | * lets tests point this at a temp dir without touching the repo's data/ folder. |
| 63 | */ |
| 64 | function refreshFilePath() { |
| 65 | const dataDir = process.env.KNOWTATION_GATEWAY_DATA_DIR || path.join(projectRoot, 'data'); |
| 66 | return path.join(dataDir, 'hosted_refresh_tokens.json'); |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * The (eventual-consistency) Netlify Blob store, set per-invocation by the Netlify function |
| 71 | * wrapper. Absent outside Netlify (dev/test), in which case we fall back to a JSON file. |
| 72 | * @returns {{ get: Function, setJSON: Function } | undefined} |
| 73 | */ |
| 74 | function getBlobStore() { |
| 75 | return globalThis.__knowtation_gateway_auth_blob; |
| 76 | } |
| 77 | |
| 78 | /** |
| 79 | * Coerce arbitrary persisted JSON into a clean records map, dropping anything that is not a |
| 80 | * well-formed record. A damaged/foreign payload thus degrades to "no sessions" (fail-closed: |
| 81 | * users re-authenticate) rather than throwing on every refresh. |
| 82 | * @param {unknown} raw |
| 83 | * @returns {Record<string, object>} |
| 84 | */ |
| 85 | function normalizeRecords(raw) { |
| 86 | const tokens = raw && typeof raw === 'object' && raw.tokens && typeof raw.tokens === 'object' ? raw.tokens : {}; |
| 87 | const out = {}; |
| 88 | for (const [id, rec] of Object.entries(tokens)) { |
| 89 | if (typeof id === 'string' && rec && typeof rec === 'object' && typeof rec.token_hash === 'string') { |
| 90 | out[id] = rec; |
| 91 | } |
| 92 | } |
| 93 | return out; |
| 94 | } |
| 95 | |
| 96 | async function readFromBlob() { |
| 97 | const store = getBlobStore(); |
| 98 | const raw = await store.get(BLOB_KEY, { type: 'json' }); |
| 99 | return normalizeRecords(raw); |
| 100 | } |
| 101 | |
| 102 | async function writeToBlob(records) { |
| 103 | const store = getBlobStore(); |
| 104 | await store.setJSON(BLOB_KEY, { tokens: records || {} }); |
| 105 | } |
| 106 | |
| 107 | async function readFromFile() { |
| 108 | try { |
| 109 | const raw = await fs.readFile(refreshFilePath(), 'utf8'); |
| 110 | return normalizeRecords(JSON.parse(raw)); |
| 111 | } catch (e) { |
| 112 | if (e && e.code === 'ENOENT') return {}; |
| 113 | // Unreadable/corrupt file: fail-closed to an empty store rather than crashing the gateway. |
| 114 | return {}; |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | async function writeToFile(records) { |
| 119 | const filePath = refreshFilePath(); |
| 120 | await fs.mkdir(path.dirname(filePath), { recursive: true }); |
| 121 | // Atomic write: temp file + rename, so a crash mid-write cannot strand half-written JSON. |
| 122 | const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; |
| 123 | await fs.writeFile(tmpPath, JSON.stringify({ tokens: records || {} }, null, 2), { encoding: 'utf8', mode: 0o600 }); |
| 124 | await fs.rename(tmpPath, filePath); |
| 125 | } |
| 126 | |
| 127 | /** |
| 128 | * Load the current records map from the active backend (blob in prod, file otherwise). |
| 129 | * @returns {Promise<Record<string, object>>} |
| 130 | */ |
| 131 | export async function loadRefreshRecords() { |
| 132 | if (getBlobStore()) return readFromBlob(); |
| 133 | return readFromFile(); |
| 134 | } |
| 135 | |
| 136 | /** |
| 137 | * Persist the records map to the active backend. |
| 138 | * @param {Record<string, object>} records |
| 139 | * @returns {Promise<void>} |
| 140 | */ |
| 141 | export async function saveRefreshRecords(records) { |
| 142 | if (getBlobStore()) { |
| 143 | await writeToBlob(records); |
| 144 | return; |
| 145 | } |
| 146 | await writeToFile(records); |
| 147 | } |
| 148 | |
| 149 | /** |
| 150 | * Issue a new refresh token for a user at login and persist it. |
| 151 | * @param {string} sub - e.g. "google:123" |
| 152 | * @param {{ now?: number, tokenTtlMs?: number, familyTtlMs?: number, meta?: object }} [opts] |
| 153 | * @returns {Promise<{ token: string, id: string, familyId: string }>} |
| 154 | */ |
| 155 | export async function issueRefreshToken(sub, opts = {}) { |
| 156 | const records = await loadRefreshRecords(); |
| 157 | const result = issueToken(records, { sub, ...opts }); |
| 158 | await saveRefreshRecords(result.records); |
| 159 | return { token: result.token, id: result.id, familyId: result.familyId }; |
| 160 | } |
| 161 | |
| 162 | /** |
| 163 | * Validate + rotate a presented refresh token, persisting the new state. On reuse or |
| 164 | * revocation the whole family is burned and persisted before the failure is returned. |
| 165 | * @param {string} token |
| 166 | * @param {{ now?: number, tokenTtlMs?: number, meta?: object }} [opts] |
| 167 | * @returns {Promise<{ ok: true, token: string, sub: string } | { ok: false, reason: string, sub: string|null }>} |
| 168 | */ |
| 169 | export async function rotateRefreshToken(token, opts = {}) { |
| 170 | const records = await loadRefreshRecords(); |
| 171 | const result = rotateToken(records, token, opts); |
| 172 | // Persist whenever the records changed (success rotates; reuse/revoked burns the family). |
| 173 | await saveRefreshRecords(result.records); |
| 174 | if (result.ok) return { ok: true, token: result.token, sub: result.sub }; |
| 175 | return { ok: false, reason: result.reason, sub: result.sub }; |
| 176 | } |
| 177 | |
| 178 | /** |
| 179 | * Revoke a single refresh token (ordinary logout). |
| 180 | * @param {string} token |
| 181 | * @returns {Promise<{ revoked: boolean, sub: string|null }>} |
| 182 | */ |
| 183 | export async function revokeRefreshToken(token) { |
| 184 | const records = await loadRefreshRecords(); |
| 185 | const result = revokeToken(records, token); |
| 186 | if (result.revoked) await saveRefreshRecords(result.records); |
| 187 | return { revoked: result.revoked, sub: result.sub }; |
| 188 | } |
| 189 | |
| 190 | /** |
| 191 | * Revoke every refresh token for a user ("sign out all sessions" / compromise response). |
| 192 | * @param {string} sub |
| 193 | * @returns {Promise<{ count: number }>} |
| 194 | */ |
| 195 | export async function revokeAllRefreshTokensForSub(sub) { |
| 196 | const records = await loadRefreshRecords(); |
| 197 | const result = revokeAllForSub(records, sub); |
| 198 | if (result.count > 0) await saveRefreshRecords(result.records); |
| 199 | return { count: result.count }; |
| 200 | } |
| 201 | |
| 202 | /** |
| 203 | * Remove dead/stale records. Safe to call opportunistically (e.g. at login). |
| 204 | * @param {{ now?: number, graceMs?: number }} [opts] |
| 205 | * @returns {Promise<{ removed: number }>} |
| 206 | */ |
| 207 | export async function pruneRefreshTokens(opts = {}) { |
| 208 | const records = await loadRefreshRecords(); |
| 209 | const result = pruneExpired(records, opts); |
| 210 | if (result.removed > 0) await saveRefreshRecords(result.records); |
| 211 | return { removed: result.removed }; |
| 212 | } |
| 213 | |
| 214 | /** |
| 215 | * Build the `{ issue, rotate, revoke }` store object the auth-session handlers expect, bound |
| 216 | * to this gateway backend. All methods are async; the handlers `await` them. |
| 217 | * @returns {{ issue: Function, rotate: Function, revoke: Function }} |
| 218 | */ |
| 219 | export function createGatewayRefreshStore() { |
| 220 | return { |
| 221 | issue: (sub, opts) => issueRefreshToken(sub, opts), |
| 222 | rotate: (token, opts) => rotateRefreshToken(token, opts), |
| 223 | revoke: (token) => revokeRefreshToken(token), |
| 224 | }; |
| 225 | } |