auth-session.test.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 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 | }); |
File History
2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠
1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6
docs: accept Calendar Events v0 spec with Phase 0 security …
Human
1 day ago