auth-session.mjs file-level

at sha256:3 · 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 * 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 }