refresh-token-store.mjs file-level

at sha256:0 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:4 fix(security): pin patched transitive deps to clear Dependabot moderate… · aaronrene · Jun 11, 2026
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 }