refresh-tokens.mjs file-level

at sha256:3 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:9 feat(calendar): hosted bridge/gateway route parity and timeline noteRec… · aaronrene · Jun 19, 2026
1 /**
2 * Durable refresh-token store for the self-hosted Hub.
3 *
4 * Persists refresh-token records to `data/hub_refresh_tokens.json` and delegates ALL
5 * security logic (rotation, reuse detection, hashing, expiry) to the pure, audited
6 * `hub/lib/refresh-token-core.mjs`. This file is intentionally thin: it only does I/O.
7 * The hosted product reuses the same core but persists to a Netlify Blob via the bridge,
8 * so the dangerous logic lives in exactly one place.
9 *
10 * File format:
11 * { "tokens": { "<id>": { sub, family_id, token_hash, created_at, expires_at,
12 * family_expires_at, rotated_to, used_at, revoked, meta } } }
13 *
14 * Writes are atomic (write to a temp file, then rename) so a crash mid-write cannot
15 * corrupt the store or strand a half-written JSON file. The store is keyed only by
16 * non-secret values; raw token secrets are never written to disk.
17 */
18
19 import fs from 'node:fs';
20 import path from 'node:path';
21 import {
22 issueToken,
23 rotateToken,
24 revokeToken,
25 revokeAllForSub,
26 pruneExpired,
27 } from './lib/refresh-token-core.mjs';
28
29 const REFRESH_TOKENS_FILE = 'hub_refresh_tokens.json';
30
31 /**
32 * Resolve the absolute path to the refresh-token store file.
33 * @param {string} dataDir
34 * @returns {string}
35 */
36 function filePathFor(dataDir) {
37 return path.join(dataDir, REFRESH_TOKENS_FILE);
38 }
39
40 /**
41 * Read the records map from disk. Returns an empty map if the file is missing or
42 * unreadable/corrupt — a damaged store fails closed (everyone must re-authenticate)
43 * rather than throwing on every request.
44 * @param {string} dataDir
45 * @returns {Record<string, object>}
46 */
47 export function readRefreshTokens(dataDir) {
48 if (!dataDir) return {};
49 const filePath = filePathFor(dataDir);
50 try {
51 if (!fs.existsSync(filePath)) return {};
52 const raw = fs.readFileSync(filePath, 'utf8');
53 const data = JSON.parse(raw);
54 const tokens = data && typeof data.tokens === 'object' && data.tokens !== null ? data.tokens : {};
55 const out = {};
56 for (const [id, rec] of Object.entries(tokens)) {
57 if (typeof id === 'string' && rec && typeof rec === 'object' && typeof rec.token_hash === 'string') {
58 out[id] = rec;
59 }
60 }
61 return out;
62 } catch (_) {
63 return {};
64 }
65 }
66
67 /**
68 * Persist the records map atomically (temp file + rename).
69 * @param {string} dataDir
70 * @param {Record<string, object>} records
71 */
72 export function writeRefreshTokens(dataDir, records) {
73 if (!dataDir) throw new Error('data_dir required');
74 fs.mkdirSync(dataDir, { recursive: true });
75 const filePath = filePathFor(dataDir);
76 const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
77 const payload = JSON.stringify({ tokens: records || {} }, null, 2);
78 fs.writeFileSync(tmpPath, payload, { encoding: 'utf8', mode: 0o600 });
79 fs.renameSync(tmpPath, filePath);
80 }
81
82 /**
83 * Issue a new refresh token for a user at login and persist it.
84 * @param {string} dataDir
85 * @param {string} sub - e.g. "google:123"
86 * @param {{ now?: number, tokenTtlMs?: number, familyTtlMs?: number, meta?: object }} [opts]
87 * @returns {{ token: string, id: string, familyId: string }} the raw token to hand to the client
88 */
89 export function issueRefreshToken(dataDir, sub, opts = {}) {
90 const records = readRefreshTokens(dataDir);
91 const result = issueToken(records, { sub, ...opts });
92 writeRefreshTokens(dataDir, result.records);
93 return { token: result.token, id: result.id, familyId: result.familyId };
94 }
95
96 /**
97 * Validate + rotate a presented refresh token, persisting the new state. On reuse or
98 * revocation the family is burned and persisted before returning the failure.
99 * @param {string} dataDir
100 * @param {string} token
101 * @param {{ now?: number, tokenTtlMs?: number, meta?: object }} [opts]
102 * @returns {{ ok: true, token: string, sub: string } | { ok: false, reason: string, sub: string|null }}
103 */
104 export function rotateRefreshToken(dataDir, token, opts = {}) {
105 const records = readRefreshTokens(dataDir);
106 const result = rotateToken(records, token, opts);
107 // Persist whenever the records changed (success rotates; reuse/revoked burns the family).
108 writeRefreshTokens(dataDir, result.records);
109 if (result.ok) return { ok: true, token: result.token, sub: result.sub };
110 return { ok: false, reason: result.reason, sub: result.sub };
111 }
112
113 /**
114 * Revoke a single refresh token (ordinary logout).
115 * @param {string} dataDir
116 * @param {string} token
117 * @returns {{ revoked: boolean, sub: string|null }}
118 */
119 export function revokeRefreshToken(dataDir, token) {
120 const records = readRefreshTokens(dataDir);
121 const result = revokeToken(records, token);
122 if (result.revoked) writeRefreshTokens(dataDir, result.records);
123 return { revoked: result.revoked, sub: result.sub };
124 }
125
126 /**
127 * Revoke every refresh token for a user ("sign out all sessions" / compromise response).
128 * @param {string} dataDir
129 * @param {string} sub
130 * @returns {{ count: number }}
131 */
132 export function revokeAllRefreshTokensForSub(dataDir, sub) {
133 const records = readRefreshTokens(dataDir);
134 const result = revokeAllForSub(records, sub);
135 if (result.count > 0) writeRefreshTokens(dataDir, result.records);
136 return { count: result.count };
137 }
138
139 /**
140 * Remove dead/stale records. Safe to call periodically or opportunistically at login.
141 * @param {string} dataDir
142 * @param {{ now?: number, graceMs?: number }} [opts]
143 * @returns {{ removed: number }}
144 */
145 export function pruneRefreshTokens(dataDir, opts = {}) {
146 const records = readRefreshTokens(dataDir);
147 const result = pruneExpired(records, opts);
148 if (result.removed > 0) writeRefreshTokens(dataDir, result.records);
149 return { removed: result.removed };
150 }