auth-session.mjs
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | /** |
| 2 | * Auth session orchestration — HttpOnly refresh-cookie handling + refresh/logout HTTP |
| 3 | * handlers, built to be dependency-injected and therefore unit-testable without booting |
| 4 | * a server. |
| 5 | * |
| 6 | * Security logic (rotation, reuse detection, hashing, expiry) lives in |
| 7 | * `hub/lib/refresh-token-core.mjs` and is reached only through an injected `store` |
| 8 | * (`{ rotate, revoke, issue }`). This module owns the transport-level concerns: |
| 9 | * |
| 10 | * - The refresh token is delivered as an **HttpOnly, Secure, SameSite** cookie scoped |
| 11 | * to the auth path, so browser JavaScript (and therefore XSS) cannot read it. This is |
| 12 | * the property `localStorage` cannot provide. |
| 13 | * - The access token is returned in the JSON body for the client to hold in memory only. |
| 14 | * - On reuse/expiry/revocation the cookie is cleared and a stable error code is returned. |
| 15 | * |
| 16 | * Both the self-hosted Hub (synchronous file store) and the hosted gateway (asynchronous |
| 17 | * Netlify-Blob store) reuse these factories by injecting their own store and cookie policy. |
| 18 | * Every store call is `await`ed so a Promise-returning store works; awaiting a synchronous |
| 19 | * return value is a no-op, so the same code drives both deployments with zero duplication of |
| 20 | * the security-sensitive rotation logic. |
| 21 | */ |
| 22 | |
| 23 | /** Name of the HttpOnly refresh-token cookie. */ |
| 24 | export const REFRESH_COOKIE_NAME = 'ktn_refresh'; |
| 25 | |
| 26 | /** Path the refresh cookie is scoped to, so it is only ever sent to the auth endpoints. */ |
| 27 | export const REFRESH_COOKIE_PATH = '/api/v1/auth'; |
| 28 | |
| 29 | /** |
| 30 | * Build the cookie options object for `res.cookie(...)`. |
| 31 | * @param {{ secure?: boolean, sameSite?: 'lax'|'strict'|'none', path?: string, maxAgeMs?: number }} [opts] |
| 32 | * @returns {object} express cookie options (always HttpOnly) |
| 33 | */ |
| 34 | export function refreshCookieOptions(opts = {}) { |
| 35 | const sameSite = opts.sameSite || 'lax'; |
| 36 | // SameSite=None is only honored by browsers when Secure is also set; enforce that here |
| 37 | // so a cross-origin deployment cannot accidentally ship a cookie browsers will drop. |
| 38 | const secure = sameSite === 'none' ? true : opts.secure !== false; |
| 39 | const out = { |
| 40 | httpOnly: true, |
| 41 | secure, |
| 42 | sameSite, |
| 43 | path: opts.path || REFRESH_COOKIE_PATH, |
| 44 | }; |
| 45 | if (Number.isFinite(opts.maxAgeMs)) out.maxAge = opts.maxAgeMs; |
| 46 | return out; |
| 47 | } |
| 48 | |
| 49 | /** |
| 50 | * Options used to CLEAR a cookie. Must match name/path/secure/sameSite/httpOnly of the set |
| 51 | * cookie (but never maxAge/expires) or the browser will not remove it. |
| 52 | * @param {object} setOptions - the object returned by refreshCookieOptions() |
| 53 | * @returns {object} |
| 54 | */ |
| 55 | export function clearCookieOptions(setOptions) { |
| 56 | const { httpOnly, secure, sameSite, path } = setOptions || {}; |
| 57 | return { httpOnly, secure, sameSite, path: path || REFRESH_COOKIE_PATH }; |
| 58 | } |
| 59 | |
| 60 | /** Map a core failure reason to a stable API error code + message (no internal detail leaked). */ |
| 61 | function refreshError(reason) { |
| 62 | switch (reason) { |
| 63 | case 'reuse': |
| 64 | // A rotated token was replayed — likely theft. Family is already revoked by the core. |
| 65 | return { code: 'REFRESH_REUSE', message: 'Session was invalidated. Please sign in again.' }; |
| 66 | case 'expired': |
| 67 | return { code: 'REFRESH_EXPIRED', message: 'Session expired. Please sign in again.' }; |
| 68 | case 'revoked': |
| 69 | return { code: 'REFRESH_REVOKED', message: 'Session was revoked. Please sign in again.' }; |
| 70 | default: |
| 71 | return { code: 'UNAUTHORIZED', message: 'Invalid session.' }; |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | /** |
| 76 | * Read the presented refresh token from the request: cookie first (browser flow), then a |
| 77 | * JSON body field (programmatic clients, matching the documented `POST /auth/refresh`). |
| 78 | * @param {object} req |
| 79 | * @param {string} cookieName |
| 80 | * @returns {string|null} |
| 81 | */ |
| 82 | function readPresentedToken(req, cookieName) { |
| 83 | const fromCookie = req && req.cookies && typeof req.cookies[cookieName] === 'string' ? req.cookies[cookieName] : null; |
| 84 | if (fromCookie) return fromCookie; |
| 85 | const fromBody = req && req.body && typeof req.body.refresh_token === 'string' ? req.body.refresh_token : null; |
| 86 | return fromBody || null; |
| 87 | } |
| 88 | |
| 89 | /** |
| 90 | * Issue a fresh refresh token for a user and set it as the HttpOnly cookie. Call at the end |
| 91 | * of a successful OAuth login. |
| 92 | * |
| 93 | * @param {object} res - express response |
| 94 | * @param {{ |
| 95 | * store: { issue: (sub: string, opts?: object) => ({ token: string } | Promise<{ token: string }>) }, |
| 96 | * sub: string, |
| 97 | * cookieName?: string, |
| 98 | * cookieOptions: () => object, |
| 99 | * now?: number, |
| 100 | * meta?: object, |
| 101 | * }} deps |
| 102 | * @returns {Promise<string>} the raw refresh token (also set as the cookie) |
| 103 | */ |
| 104 | export async function issueRefreshCookie(res, deps) { |
| 105 | const cookieName = deps.cookieName || REFRESH_COOKIE_NAME; |
| 106 | const opts = deps.cookieOptions(); |
| 107 | const { token } = await deps.store.issue(deps.sub, { now: deps.now, meta: deps.meta }); |
| 108 | res.cookie(cookieName, token, opts); |
| 109 | return token; |
| 110 | } |
| 111 | |
| 112 | /** |
| 113 | * Build the `POST /auth/refresh` handler. Rotates the presented refresh token, sets the new |
| 114 | * cookie, and returns a freshly-signed access token in the body. |
| 115 | * |
| 116 | * @param {{ |
| 117 | * store: { rotate: (token: string, opts?: object) => (object | Promise<object>) }, |
| 118 | * issueAccessToken: (sub: string) => (string | Promise<string>), |
| 119 | * cookieName?: string, |
| 120 | * cookieOptions: () => object, |
| 121 | * now?: () => number, |
| 122 | * meta?: (req: object) => object, |
| 123 | * }} deps |
| 124 | * @returns {(req: object, res: object) => Promise<void>} |
| 125 | */ |
| 126 | export function createRefreshHandler(deps) { |
| 127 | const cookieName = deps.cookieName || REFRESH_COOKIE_NAME; |
| 128 | const nowFn = typeof deps.now === 'function' ? deps.now : () => Date.now(); |
| 129 | return async function refreshHandler(req, res) { |
| 130 | res.set('Cache-Control', 'private, no-store, must-revalidate'); |
| 131 | const presented = readPresentedToken(req, cookieName); |
| 132 | if (!presented) { |
| 133 | return res.status(401).json({ error: 'Missing refresh token', code: 'UNAUTHORIZED' }); |
| 134 | } |
| 135 | let result; |
| 136 | try { |
| 137 | result = await deps.store.rotate(presented, { |
| 138 | now: nowFn(), |
| 139 | meta: typeof deps.meta === 'function' ? deps.meta(req) : undefined, |
| 140 | }); |
| 141 | } catch (_) { |
| 142 | // A transient store fault (e.g. blob backend hiccup) must not be treated as token |
| 143 | // theft: do NOT clear the cookie. Fail soft with 503 so the client can retry rather |
| 144 | // than forcing the user to re-authenticate over an infrastructure blip. |
| 145 | return res.status(503).json({ error: 'Session service temporarily unavailable.', code: 'SESSION_STORE_UNAVAILABLE' }); |
| 146 | } |
| 147 | if (!result.ok) { |
| 148 | res.clearCookie(cookieName, clearCookieOptions(deps.cookieOptions())); |
| 149 | const e = refreshError(result.reason); |
| 150 | return res.status(401).json({ error: e.message, code: e.code }); |
| 151 | } |
| 152 | res.cookie(cookieName, result.token, deps.cookieOptions()); |
| 153 | const accessToken = await deps.issueAccessToken(result.sub); |
| 154 | return res.json({ access_token: accessToken, token_type: 'Bearer' }); |
| 155 | }; |
| 156 | } |
| 157 | |
| 158 | /** |
| 159 | * Build the `POST /auth/logout` handler. Revokes the presented refresh token server-side |
| 160 | * (real revocation, not just clearing the client) and clears the cookie. Idempotent. |
| 161 | * |
| 162 | * @param {{ |
| 163 | * store: { revoke: (token: string) => ({ revoked: boolean } | Promise<{ revoked: boolean }>) }, |
| 164 | * cookieName?: string, |
| 165 | * cookieOptions: () => object, |
| 166 | * }} deps |
| 167 | * @returns {(req: object, res: object) => Promise<void>} |
| 168 | */ |
| 169 | export function createLogoutHandler(deps) { |
| 170 | const cookieName = deps.cookieName || REFRESH_COOKIE_NAME; |
| 171 | return async function logoutHandler(req, res) { |
| 172 | res.set('Cache-Control', 'private, no-store, must-revalidate'); |
| 173 | const presented = readPresentedToken(req, cookieName); |
| 174 | let revoked = false; |
| 175 | if (presented) { |
| 176 | try { |
| 177 | const r = await deps.store.revoke(presented); |
| 178 | revoked = Boolean(r && r.revoked); |
| 179 | } catch (_) { |
| 180 | revoked = false; |
| 181 | } |
| 182 | } |
| 183 | res.clearCookie(cookieName, clearCookieOptions(deps.cookieOptions())); |
| 184 | return res.json({ ok: true, revoked }); |
| 185 | }; |
| 186 | } |