auth-session.test.mjs file-level

at sha256:8 · 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 * Auth-session handler tests — refresh/logout HTTP orchestration + cookie policy.
3 *
4 * Tiers: unit (cookie option policy), integration (handlers against the REAL file store
5 * through a temp dir), security (reuse clears cookie + REFRESH_REUSE, missing token 401,
6 * HttpOnly always set, SameSite=None forces Secure).
7 */
8
9 import { describe, it, beforeEach, afterEach } from 'node:test';
10 import assert from 'node:assert/strict';
11 import fs from 'node:fs';
12 import os from 'node:os';
13 import path from 'node:path';
14 import {
15 REFRESH_COOKIE_NAME,
16 REFRESH_COOKIE_PATH,
17 refreshCookieOptions,
18 clearCookieOptions,
19 issueRefreshCookie,
20 createRefreshHandler,
21 createLogoutHandler,
22 } from '../hub/auth-session.mjs';
23 import {
24 issueRefreshToken,
25 rotateRefreshToken,
26 revokeRefreshToken,
27 } from '../hub/refresh-tokens.mjs';
28
29 /** Minimal express-like response double that records what handlers do. */
30 function mockRes() {
31 return {
32 statusCode: 200,
33 body: null,
34 headers: {},
35 cookies: [],
36 clearedCookies: [],
37 set(k, v) { this.headers[k] = v; return this; },
38 status(code) { this.statusCode = code; return this; },
39 json(obj) { this.body = obj; return this; },
40 cookie(name, value, options) { this.cookies.push({ name, value, options }); return this; },
41 clearCookie(name, options) { this.clearedCookies.push({ name, options }); return this; },
42 };
43 }
44
45 /** Build a store bound to a temp data dir, matching the shape auth-session expects. */
46 function fileStore(dataDir) {
47 return {
48 issue: (sub, opts) => issueRefreshToken(dataDir, sub, opts),
49 rotate: (token, opts) => rotateRefreshToken(dataDir, token, opts),
50 revoke: (token) => revokeRefreshToken(dataDir, token),
51 };
52 }
53
54 const cookieOptions = () => refreshCookieOptions({ secure: true, sameSite: 'lax', maxAgeMs: 1000 });
55 const issueAccessToken = (sub) => `access-for-${sub}`;
56
57 let dataDir;
58 beforeEach(() => { dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'knowtation-sess-')); });
59 afterEach(() => { try { fs.rmSync(dataDir, { recursive: true, force: true }); } catch (_) { /* noop */ } });
60
61 // ---------------------------------------------------------------------------
62 // unit — cookie policy
63 // ---------------------------------------------------------------------------
64 describe('cookie policy', () => {
65 it('always sets HttpOnly and defaults to the auth path', () => {
66 const o = refreshCookieOptions();
67 assert.equal(o.httpOnly, true);
68 assert.equal(o.path, REFRESH_COOKIE_PATH);
69 });
70
71 it('SameSite=None forces Secure (browsers drop None without Secure)', () => {
72 const o = refreshCookieOptions({ sameSite: 'none', secure: false });
73 assert.equal(o.sameSite, 'none');
74 assert.equal(o.secure, true);
75 });
76
77 it('clearCookieOptions mirrors set options but omits maxAge', () => {
78 const set = refreshCookieOptions({ secure: true, sameSite: 'lax', maxAgeMs: 5000 });
79 const clear = clearCookieOptions(set);
80 assert.equal(clear.maxAge, undefined);
81 assert.equal(clear.httpOnly, true);
82 assert.equal(clear.path, REFRESH_COOKIE_PATH);
83 assert.equal(clear.sameSite, 'lax');
84 });
85 });
86
87 // ---------------------------------------------------------------------------
88 // integration — issue + refresh + logout through the real file store
89 // ---------------------------------------------------------------------------
90 describe('issueRefreshCookie', () => {
91 it('sets an HttpOnly cookie and returns the token', async () => {
92 const res = mockRes();
93 const token = await issueRefreshCookie(res, {
94 store: fileStore(dataDir),
95 sub: 'google:1',
96 cookieOptions,
97 now: 1000,
98 });
99 assert.ok(token.includes('.'));
100 assert.equal(res.cookies.length, 1);
101 assert.equal(res.cookies[0].name, REFRESH_COOKIE_NAME);
102 assert.equal(res.cookies[0].value, token);
103 assert.equal(res.cookies[0].options.httpOnly, true);
104 });
105 });
106
107 describe('refresh handler', () => {
108 it('rotates a valid cookie token: returns access token + sets a new cookie', async () => {
109 const store = fileStore(dataDir);
110 const setRes = mockRes();
111 const token = await issueRefreshCookie(setRes, { store, sub: 'google:1', cookieOptions, now: 1000 });
112
113 const handler = createRefreshHandler({ store, issueAccessToken, cookieOptions, now: () => 2000 });
114 const req = { cookies: { [REFRESH_COOKIE_NAME]: token } };
115 const res = mockRes();
116 await handler(req, res);
117
118 assert.equal(res.statusCode, 200);
119 assert.equal(res.body.access_token, 'access-for-google:1');
120 assert.equal(res.body.token_type, 'Bearer');
121 assert.equal(res.cookies.length, 1, 'a rotated cookie must be set');
122 assert.notEqual(res.cookies[0].value, token, 'rotated token must differ');
123 assert.equal(res.headers['Cache-Control'], 'private, no-store, must-revalidate');
124 });
125
126 it('accepts the token from a JSON body when no cookie is present', async () => {
127 const store = fileStore(dataDir);
128 const token = issueRefreshToken(dataDir, 'github:9', { now: 1000 }).token;
129 const handler = createRefreshHandler({ store, issueAccessToken, cookieOptions, now: () => 2000 });
130 const res = mockRes();
131 await handler({ cookies: {}, body: { refresh_token: token } }, res);
132 assert.equal(res.statusCode, 200);
133 assert.equal(res.body.access_token, 'access-for-github:9');
134 });
135
136 it('returns 401 when no token is presented', async () => {
137 const store = fileStore(dataDir);
138 const handler = createRefreshHandler({ store, issueAccessToken, cookieOptions });
139 const res = mockRes();
140 await handler({ cookies: {} }, res);
141 assert.equal(res.statusCode, 401);
142 assert.equal(res.body.code, 'UNAUTHORIZED');
143 });
144
145 it('on REUSE: clears the cookie and returns REFRESH_REUSE', async () => {
146 const store = fileStore(dataDir);
147 const token = issueRefreshToken(dataDir, 'google:1', { now: 1000 }).token;
148 // First rotation consumes the token.
149 rotateRefreshToken(dataDir, token, { now: 2000 });
150 // Replay the original (now-rotated) token via the handler.
151 const handler = createRefreshHandler({ store, issueAccessToken, cookieOptions, now: () => 3000 });
152 const res = mockRes();
153 await handler({ cookies: { [REFRESH_COOKIE_NAME]: token } }, res);
154 assert.equal(res.statusCode, 401);
155 assert.equal(res.body.code, 'REFRESH_REUSE');
156 assert.equal(res.clearedCookies.length, 1, 'reuse must clear the cookie');
157 assert.equal(res.clearedCookies[0].options.maxAge, undefined);
158 });
159
160 it('on EXPIRED: clears the cookie and returns REFRESH_EXPIRED', async () => {
161 const store = fileStore(dataDir);
162 const token = issueRefreshToken(dataDir, 'google:1', { now: 1000, tokenTtlMs: 500 }).token;
163 const handler = createRefreshHandler({ store, issueAccessToken, cookieOptions, now: () => 5000 });
164 const res = mockRes();
165 await handler({ cookies: { [REFRESH_COOKIE_NAME]: token } }, res);
166 assert.equal(res.statusCode, 401);
167 assert.equal(res.body.code, 'REFRESH_EXPIRED');
168 assert.equal(res.clearedCookies.length, 1);
169 });
170
171 it('on a store fault: fails soft with 503 and does NOT clear the cookie', async () => {
172 // An async store whose rotate() rejects simulates a transient blob backend outage.
173 const faultyStore = {
174 issue: async () => { throw new Error('unused'); },
175 rotate: async () => { throw new Error('blob unavailable'); },
176 revoke: async () => ({ revoked: false }),
177 };
178 const handler = createRefreshHandler({ store: faultyStore, issueAccessToken, cookieOptions });
179 const res = mockRes();
180 await handler({ cookies: { [REFRESH_COOKIE_NAME]: 'id.secret' } }, res);
181 assert.equal(res.statusCode, 503);
182 assert.equal(res.body.code, 'SESSION_STORE_UNAVAILABLE');
183 assert.equal(res.clearedCookies.length, 0, 'a transient fault must not log the user out');
184 });
185
186 it('awaits an async issueAccessToken (Promise-returning signer)', async () => {
187 const store = fileStore(dataDir);
188 const token = issueRefreshToken(dataDir, 'google:7', { now: 1000 }).token;
189 const asyncSigner = async (sub) => `async-access-for-${sub}`;
190 const handler = createRefreshHandler({ store, issueAccessToken: asyncSigner, cookieOptions, now: () => 2000 });
191 const res = mockRes();
192 await handler({ cookies: { [REFRESH_COOKIE_NAME]: token } }, res);
193 assert.equal(res.body.access_token, 'async-access-for-google:7');
194 });
195 });
196
197 describe('logout handler', () => {
198 it('revokes the presented token and clears the cookie', async () => {
199 const store = fileStore(dataDir);
200 const token = issueRefreshToken(dataDir, 'google:1', { now: 1000 }).token;
201 const handler = createLogoutHandler({ store, cookieOptions });
202 const res = mockRes();
203 await handler({ cookies: { [REFRESH_COOKIE_NAME]: token } }, res);
204 assert.equal(res.body.ok, true);
205 assert.equal(res.body.revoked, true);
206 assert.equal(res.clearedCookies.length, 1);
207 // Token no longer works.
208 const after = rotateRefreshToken(dataDir, token, { now: 2000 });
209 assert.equal(after.ok, false);
210 });
211
212 it('is idempotent when no token is present (still clears + ok)', async () => {
213 const store = fileStore(dataDir);
214 const handler = createLogoutHandler({ store, cookieOptions });
215 const res = mockRes();
216 await handler({ cookies: {} }, res);
217 assert.equal(res.body.ok, true);
218 assert.equal(res.body.revoked, false);
219 assert.equal(res.clearedCookies.length, 1);
220 });
221
222 it('tolerates an async store whose revoke() rejects (still clears + ok)', async () => {
223 const faultyStore = { revoke: async () => { throw new Error('blob down'); } };
224 const handler = createLogoutHandler({ store: faultyStore, cookieOptions });
225 const res = mockRes();
226 await handler({ cookies: { [REFRESH_COOKIE_NAME]: 'id.secret' } }, res);
227 assert.equal(res.body.ok, true);
228 assert.equal(res.body.revoked, false);
229 assert.equal(res.clearedCookies.length, 1);
230 });
231 });