refresh-token-core.mjs
348 lines 14.2 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Refresh-token core — storage-agnostic logic for persistent, secure sessions.
3 *
4 * This module implements OAuth 2.0 refresh-token rotation with reuse detection
5 * (the pattern recommended by OWASP and RFC 6819 §5.2.2.3). It deliberately holds
6 * NO state and performs NO I/O: callers pass in a plain `records` object (the set
7 * of currently-valid refresh-token records) and a `now` timestamp, and receive back
8 * a new `records` object to persist however they like (a JSON file for the
9 * self-hosted Hub, a Netlify Blob for the hosted bridge, a DB row, etc.). Keeping the
10 * security logic pure makes it exhaustively unit-testable and reusable across every
11 * deployment surface without duplicating the dangerous parts.
12 *
13 * ## Threat model and design choices
14 *
15 * - **Opaque, not a JWT.** A refresh token is high-entropy random bytes, never a
16 * signed claim. It is meaningless to anyone who cannot match it against the server
17 * store, so a leaked token is useless once revoked. This is the property the access
18 * JWT cannot provide (a valid JWT is accepted until it expires, with no server check).
19 *
20 * - **Hash at rest.** Only `sha256(secret)` is stored. The raw secret is returned to
21 * the caller exactly once (to hand to the client) and never persisted. A read-only
22 * leak of the store (e.g. a backup) therefore does not expose usable tokens. SHA-256
23 * (not bcrypt/scrypt) is appropriate here precisely because the secret is 256 bits of
24 * CSPRNG output — there is nothing to brute-force, so a slow KDF would add cost without
25 * adding security.
26 *
27 * - **Lookup id separate from secret.** The token presented to the client is
28 * `"<id>.<secret>"`. The `id` lets the server find the one record in O(1) and compare
29 * the secret in constant time, instead of scanning every record (which would be both
30 * slow and a timing-oracle).
31 *
32 * - **Rotation + reuse detection.** Every successful refresh consumes the presented
33 * token and issues a brand-new one in the same *family*. If a token that has already
34 * been rotated is presented again, that is the signature of a stolen token being
35 * replayed: the entire family is revoked immediately, logging out both the attacker
36 * and the victim (who must re-authenticate). This bounds the damage of a leaked
37 * refresh token to, at most, the window before either party next refreshes.
38 *
39 * - **Absolute family cap.** Rotation alone would let a session live forever. Each
40 * family carries `family_expires_at`, an absolute ceiling after which no rotation is
41 * honored regardless of per-token expiry — so re-authentication is eventually forced.
42 *
43 * Times are milliseconds since the Unix epoch (numbers), and `now` is always injected,
44 * so behavior is deterministic and testable without faking the system clock.
45 */
46
47 import crypto from 'node:crypto';
48
49 /** Default per-token lifetime: a single refresh token is valid for 30 days of inactivity. */
50 export const DEFAULT_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
51 /** Default absolute family lifetime: a session may be rotated for at most 90 days before re-auth. */
52 export const DEFAULT_FAMILY_TTL_MS = 90 * 24 * 60 * 60 * 1000;
53
54 /** Bytes of entropy for the secret half of a token (256 bits). */
55 const SECRET_BYTES = 32;
56 /** Bytes of entropy for the lookup id (128 bits — collision-resistant, not secret). */
57 const ID_BYTES = 16;
58
59 /** Discriminated reasons a refresh attempt can fail. Stable strings — safe to map to API codes. */
60 export const REFRESH_FAILURE = Object.freeze({
61 INVALID: 'invalid', // unknown id, malformed token, or secret mismatch
62 EXPIRED: 'expired', // per-token or family lifetime elapsed
63 REVOKED: 'revoked', // token was explicitly revoked (e.g. logout, or family compromise)
64 REUSE: 'reuse', // an already-rotated token was replayed — family is revoked as a result
65 });
66
67 /**
68 * Hash a token secret for storage/comparison. Uses SHA-256 over the high-entropy secret.
69 * @param {string} secret - the random secret half of a token
70 * @returns {string} base64url-encoded digest
71 */
72 export function hashSecret(secret) {
73 return crypto.createHash('sha256').update(String(secret)).digest('base64url');
74 }
75
76 /**
77 * Constant-time string comparison that does not leak length via early return timing
78 * beyond the unavoidable length check. Both inputs are hashed to fixed-length buffers
79 * first so the comparison itself is always over equal-length buffers.
80 * @param {string} a
81 * @param {string} b
82 * @returns {boolean}
83 */
84 function safeEqualHashes(a, b) {
85 if (typeof a !== 'string' || typeof b !== 'string') return false;
86 const ab = Buffer.from(a);
87 const bb = Buffer.from(b);
88 if (ab.length !== bb.length) return false;
89 return crypto.timingSafeEqual(ab, bb);
90 }
91
92 /**
93 * Generate a new opaque refresh token.
94 * @returns {{ id: string, secret: string, token: string, tokenHash: string }}
95 * `token` is what the client receives (`"<id>.<secret>"`); `tokenHash` is what the
96 * server persists. The raw `secret` must never be stored.
97 */
98 export function generateRefreshToken() {
99 const id = crypto.randomBytes(ID_BYTES).toString('base64url');
100 const secret = crypto.randomBytes(SECRET_BYTES).toString('base64url');
101 return { id, secret, token: `${id}.${secret}`, tokenHash: hashSecret(secret) };
102 }
103
104 /**
105 * Parse a client-presented token string into its id and secret halves.
106 * @param {unknown} token
107 * @returns {{ id: string, secret: string } | null} null if malformed
108 */
109 export function parseToken(token) {
110 if (typeof token !== 'string' || token.length === 0) return null;
111 const dot = token.indexOf('.');
112 if (dot <= 0 || dot === token.length - 1) return null;
113 const id = token.slice(0, dot);
114 const secret = token.slice(dot + 1);
115 if (!id || !secret || secret.includes('.')) return null;
116 return { id, secret };
117 }
118
119 /**
120 * Shallow-clone a records map so core functions never mutate the caller's object.
121 * @param {Record<string, object>} records
122 * @returns {Record<string, object>}
123 */
124 function cloneRecords(records) {
125 const out = {};
126 if (records && typeof records === 'object') {
127 for (const [k, v] of Object.entries(records)) {
128 if (v && typeof v === 'object') out[k] = { ...v };
129 }
130 }
131 return out;
132 }
133
134 /**
135 * Issue a brand-new refresh token, starting a new family (call at login).
136 *
137 * @param {Record<string, object>} records - current store contents
138 * @param {{ sub: string, now?: number, tokenTtlMs?: number, familyTtlMs?: number, meta?: object }} opts
139 * @returns {{ records: Record<string, object>, token: string, id: string, familyId: string }}
140 * @throws {Error} if `sub` is missing
141 */
142 export function issueToken(records, opts) {
143 const { sub } = opts || {};
144 if (typeof sub !== 'string' || !sub.trim()) {
145 throw new Error('issueToken: sub is required');
146 }
147 const now = Number.isFinite(opts.now) ? opts.now : Date.now();
148 const tokenTtlMs = Number.isFinite(opts.tokenTtlMs) ? opts.tokenTtlMs : DEFAULT_TOKEN_TTL_MS;
149 const familyTtlMs = Number.isFinite(opts.familyTtlMs) ? opts.familyTtlMs : DEFAULT_FAMILY_TTL_MS;
150
151 const next = cloneRecords(records);
152 const { id, token, tokenHash } = generateRefreshToken();
153 const familyId = crypto.randomBytes(ID_BYTES).toString('base64url');
154
155 next[id] = {
156 sub: sub.trim(),
157 family_id: familyId,
158 token_hash: tokenHash,
159 created_at: now,
160 expires_at: now + tokenTtlMs,
161 family_expires_at: now + familyTtlMs,
162 rotated_to: null,
163 used_at: null,
164 revoked: false,
165 meta: sanitizeMeta(opts.meta),
166 };
167
168 return { records: next, token, id, familyId };
169 }
170
171 /**
172 * Keep only small, known string fields from caller-supplied metadata (e.g. user agent,
173 * IP) so we never persist arbitrary/unbounded data into the token store.
174 * @param {object|undefined} meta
175 * @returns {{ ua?: string, ip?: string }}
176 */
177 function sanitizeMeta(meta) {
178 const out = {};
179 if (meta && typeof meta === 'object') {
180 if (typeof meta.ua === 'string') out.ua = meta.ua.slice(0, 256);
181 if (typeof meta.ip === 'string') out.ip = meta.ip.slice(0, 64);
182 }
183 return out;
184 }
185
186 /**
187 * Mark every token in a family as revoked. Used both for explicit "log out everywhere"
188 * within a family and automatically when token reuse is detected.
189 * @param {Record<string, object>} records
190 * @param {string} familyId
191 * @param {number} now
192 * @returns {Record<string, object>} new records
193 */
194 export function revokeFamily(records, familyId, now = Date.now()) {
195 const next = cloneRecords(records);
196 for (const rec of Object.values(next)) {
197 if (rec.family_id === familyId && !rec.revoked) {
198 rec.revoked = true;
199 rec.revoked_at = now;
200 }
201 }
202 return next;
203 }
204
205 /**
206 * Revoke (delete) the single token matching the presented token string. Safe no-op if
207 * the token is unknown or malformed. Used for ordinary logout of one session.
208 * @param {Record<string, object>} records
209 * @param {string} token
210 * @returns {{ records: Record<string, object>, revoked: boolean, sub: string|null }}
211 */
212 export function revokeToken(records, token) {
213 const parsed = parseToken(token);
214 const next = cloneRecords(records);
215 if (!parsed) return { records: next, revoked: false, sub: null };
216 const rec = next[parsed.id];
217 if (!rec) return { records: next, revoked: false, sub: null };
218 // Only delete if the secret actually matches, so a known id alone cannot evict a session.
219 if (!safeEqualHashes(rec.token_hash, hashSecret(parsed.secret))) {
220 return { records: next, revoked: false, sub: null };
221 }
222 const sub = rec.sub;
223 delete next[parsed.id];
224 return { records: next, revoked: true, sub };
225 }
226
227 /**
228 * Revoke every token belonging to a user (e.g. admin "sign out all sessions", or a
229 * password/identity compromise). Returns count for audit logging.
230 * @param {Record<string, object>} records
231 * @param {string} sub
232 * @returns {{ records: Record<string, object>, count: number }}
233 */
234 export function revokeAllForSub(records, sub) {
235 const next = cloneRecords(records);
236 let count = 0;
237 for (const [id, rec] of Object.entries(next)) {
238 if (rec.sub === sub) {
239 delete next[id];
240 count += 1;
241 }
242 }
243 return { records: next, count };
244 }
245
246 /**
247 * Validate a presented refresh token and, on success, rotate it: consume the old token
248 * and mint a new one in the same family. On detection of replay of an already-rotated
249 * token, revoke the whole family.
250 *
251 * @param {Record<string, object>} records
252 * @param {string} token - client-presented `"<id>.<secret>"`
253 * @param {{ now?: number, tokenTtlMs?: number, meta?: object }} [opts]
254 * @returns {
255 * { ok: true, records: Record<string, object>, token: string, id: string, sub: string, familyId: string }
256 * | { ok: false, records: Record<string, object>, reason: string, sub: string|null }
257 * }
258 */
259 export function rotateToken(records, token, opts = {}) {
260 const now = Number.isFinite(opts.now) ? opts.now : Date.now();
261 const tokenTtlMs = Number.isFinite(opts.tokenTtlMs) ? opts.tokenTtlMs : DEFAULT_TOKEN_TTL_MS;
262
263 const parsed = parseToken(token);
264 if (!parsed) {
265 return { ok: false, records: cloneRecords(records), reason: REFRESH_FAILURE.INVALID, sub: null };
266 }
267
268 let next = cloneRecords(records);
269 const rec = next[parsed.id];
270 if (!rec) {
271 return { ok: false, records: next, reason: REFRESH_FAILURE.INVALID, sub: null };
272 }
273
274 // Verify the secret before trusting any field on the record.
275 if (!safeEqualHashes(rec.token_hash, hashSecret(parsed.secret))) {
276 return { ok: false, records: next, reason: REFRESH_FAILURE.INVALID, sub: null };
277 }
278
279 // Already revoked (logout or prior family compromise): reject, and make sure the whole
280 // family is down in case revocation was partial.
281 if (rec.revoked) {
282 next = revokeFamily(next, rec.family_id, now);
283 return { ok: false, records: next, reason: REFRESH_FAILURE.REVOKED, sub: rec.sub };
284 }
285
286 // Replay of an already-rotated token == theft signal. Burn the entire family.
287 if (rec.rotated_to) {
288 next = revokeFamily(next, rec.family_id, now);
289 return { ok: false, records: next, reason: REFRESH_FAILURE.REUSE, sub: rec.sub };
290 }
291
292 // Expiry checks (per-token inactivity AND absolute family ceiling).
293 if (now >= rec.expires_at || now >= rec.family_expires_at) {
294 delete next[parsed.id];
295 return { ok: false, records: next, reason: REFRESH_FAILURE.EXPIRED, sub: rec.sub };
296 }
297
298 // Success: mint a successor in the same family, capped by the same absolute ceiling.
299 const fresh = generateRefreshToken();
300 next[fresh.id] = {
301 sub: rec.sub,
302 family_id: rec.family_id,
303 token_hash: fresh.tokenHash,
304 created_at: now,
305 expires_at: now + tokenTtlMs,
306 family_expires_at: rec.family_expires_at,
307 rotated_to: null,
308 used_at: null,
309 revoked: false,
310 meta: sanitizeMeta(opts.meta) || rec.meta,
311 };
312
313 // Consume the old token: keep a tombstone (marked used + pointing at successor) so a
314 // later replay is detected as reuse rather than silently treated as "unknown".
315 rec.used_at = now;
316 rec.rotated_to = fresh.id;
317
318 return { ok: true, records: next, token: fresh.token, id: fresh.id, sub: rec.sub, familyId: rec.family_id };
319 }
320
321 /**
322 * Drop records that can never succeed again: expired tokens past a grace period, and
323 * consumed/revoked tombstones older than the grace window. The grace window keeps recent
324 * tombstones so reuse detection still fires for a stolen token replayed shortly after
325 * rotation; after the window, the family is moot and the rows are just clutter.
326 *
327 * @param {Record<string, object>} records
328 * @param {{ now?: number, graceMs?: number }} [opts]
329 * @returns {{ records: Record<string, object>, removed: number }}
330 */
331 export function pruneExpired(records, opts = {}) {
332 const now = Number.isFinite(opts.now) ? opts.now : Date.now();
333 // Keep tombstones for the full family lifetime by default so reuse detection is reliable.
334 const graceMs = Number.isFinite(opts.graceMs) ? opts.graceMs : DEFAULT_FAMILY_TTL_MS;
335 const next = cloneRecords(records);
336 let removed = 0;
337 for (const [id, rec] of Object.entries(next)) {
338 const familyDead = now >= (rec.family_expires_at ?? 0);
339 const tokenDead = now >= (rec.expires_at ?? 0);
340 const consumed = Boolean(rec.rotated_to) || rec.revoked === true;
341 const pastGrace = now >= ((rec.used_at ?? rec.revoked_at ?? rec.created_at ?? 0) + graceMs);
342 if (familyDead || (tokenDead && (!consumed || pastGrace)) || (consumed && pastGrace)) {
343 delete next[id];
344 removed += 1;
345 }
346 }
347 return { records: next, removed };
348 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago