refresh-tokens.mjs
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 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 | } |