|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#23813] A minted principal session is not REFRESHED by a read — not merely |
| 5 | + * held to its expiry (#23772's pins), but never renewed at all. |
| 6 | + * |
| 7 | + * The renewal #23772 clamps still wrote the row (`updatedAt`), re-set the |
| 8 | + * session cookie and so made `bearer()` answer `set-auth-token` on every |
| 9 | + * request. The console reads that header as a token rotation and re-resolves |
| 10 | + * identity, then re-reads its organizations, whose session reads renew again: |
| 11 | + * measured on the real console, it never left "Opening your console...". So |
| 12 | + * the pin is the wire a client sees: on both lanes, `get-session` and an |
| 13 | + * endpoint that reads the session through better-auth's middleware answer |
| 14 | + * with no `set-auth-token`, no session `Set-Cookie`, and the row unwritten. |
| 15 | + * The controls prove the same reads DO refresh an unbranded short session, |
| 16 | + * so the pins cannot pass on a fixture that never renews. |
| 17 | + * |
| 18 | + * Real better-auth, real request seam (`AuthManager.handleRequest`), the |
| 19 | + * `sys_session` row read straight off the engine's table. |
| 20 | + */ |
| 21 | + |
| 22 | +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; |
| 23 | +import { createMemoryEngine } from './impersonation-bearer-rotation.test'; |
| 24 | +import { bootMintFixture, expiresAtMs, MINT_BASE, rawTokenOf, type MintFixture } from './principal-session-mint-test-support'; |
| 25 | +import { carriesPrincipalSession, PRINCIPAL_SESSION_TOKEN_PREFIX } from './principal-session-mint'; |
| 26 | + |
| 27 | +let fx: MintFixture; |
| 28 | +let cookieName: string; |
| 29 | + |
| 30 | +beforeAll(async () => { |
| 31 | + vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 32 | + vi.spyOn(console, 'error').mockImplementation(() => {}); |
| 33 | + fx = await bootMintFixture(createMemoryEngine as any); |
| 34 | + cookieName = (await fx.manager.getAuthContext()).authCookies.sessionToken.name; |
| 35 | +}); |
| 36 | + |
| 37 | +afterAll(() => vi.restoreAllMocks()); |
| 38 | + |
| 39 | +/** What a client sees of one request: the status and whether it was handed a (re)issued session. */ |
| 40 | +async function wire(path: string, headers: Record<string, string>) { |
| 41 | + const res = await fx.manager.handleRequest( |
| 42 | + new Request(`${fx.origin}${MINT_BASE}${path}`, { method: 'GET', headers: { origin: fx.origin, ...headers } }), |
| 43 | + ); |
| 44 | + const setCookie = res.headers.get('set-cookie') ?? ''; |
| 45 | + return { |
| 46 | + status: res.status, |
| 47 | + setAuthToken: res.headers.has('set-auth-token'), |
| 48 | + setsSessionCookie: setCookie.split(/,(?=\s*[^;,=\s]+=)/).some((c) => c.trim().startsWith(`${cookieName}=`)), |
| 49 | + }; |
| 50 | +} |
| 51 | + |
| 52 | +const updatedAtMs = (token: string): number => { |
| 53 | + const row = fx.row(token); |
| 54 | + if (!row) throw new Error('pin: the session row is gone'); |
| 55 | + return new Date(row.updated_at as string | Date).getTime(); |
| 56 | +}; |
| 57 | + |
| 58 | +/** A short session WITHOUT the brand, signed as the cookie carries it. */ |
| 59 | +async function plainShortSession(): Promise<{ token: string; signed: string }> { |
| 60 | + const ctx: any = await fx.manager.getAuthContext(); |
| 61 | + const plain = await ctx.internalAdapter.createSession( |
| 62 | + fx.userId, false, { expiresAt: new Date(Date.now() + 120_000) }, true, |
| 63 | + ); |
| 64 | + const { makeSignature } = await import('better-auth/crypto'); |
| 65 | + return { token: plain.token, signed: encodeURIComponent(`${plain.token}.${await makeSignature(plain.token, ctx.secret)}`) }; |
| 66 | +} |
| 67 | + |
| 68 | +const LANES = { |
| 69 | + cookie: (signed: string) => ({ cookie: `${cookieName}=${signed}` }), |
| 70 | + bearer: (signed: string) => ({ authorization: `Bearer ${signed}` }), |
| 71 | +} as const; |
| 72 | + |
| 73 | +// `/get-session` is the console's boot read; `/list-sessions` reads the session |
| 74 | +// through better-auth's `sessionMiddleware`, as the organization reads do. |
| 75 | +const PATHS = ['/get-session', '/list-sessions'] as const; |
| 76 | + |
| 77 | +describe('[#23813] a minted session is never refreshed by a read', () => { |
| 78 | + for (const [lane, headersOf] of Object.entries(LANES)) { |
| 79 | + for (const path of PATHS) { |
| 80 | + it(`control (${lane}, ${path}): the same read refreshes a short session WITHOUT the brand`, async () => { |
| 81 | + const plain = await plainShortSession(); |
| 82 | + const before = updatedAtMs(plain.token); |
| 83 | + await new Promise((r) => setTimeout(r, 5)); |
| 84 | + |
| 85 | + const seen = await wire(path, headersOf(plain.signed)); |
| 86 | + |
| 87 | + expect(seen.status).toBe(200); |
| 88 | + expect( |
| 89 | + { setAuthToken: seen.setAuthToken, setsSessionCookie: seen.setsSessionCookie }, |
| 90 | + 'the fixture does not refresh — the pin below would pass vacuously', |
| 91 | + ).toEqual({ setAuthToken: true, setsSessionCookie: true }); |
| 92 | + expect(updatedAtMs(plain.token)).toBeGreaterThan(before); |
| 93 | + }); |
| 94 | + |
| 95 | + it(`${lane}, ${path}: answers with no reissued session and leaves the row unwritten`, async () => { |
| 96 | + const minted = await fx.manager.mintPrincipalSession(fx.human()); |
| 97 | + const token = rawTokenOf(minted); |
| 98 | + const updatedAt = updatedAtMs(token); |
| 99 | + const expiresAt = expiresAtMs(fx.row(token)); |
| 100 | + |
| 101 | + for (let i = 0; i < 3; i++) { |
| 102 | + await new Promise((r) => setTimeout(r, 5)); |
| 103 | + const seen = await wire(path, headersOf(minted.token)); |
| 104 | + expect(seen, `read ${i + 1} reissued the minted session`).toEqual({ |
| 105 | + status: 200, |
| 106 | + setAuthToken: false, |
| 107 | + setsSessionCookie: false, |
| 108 | + }); |
| 109 | + expect(updatedAtMs(token), `read ${i + 1} wrote the minted session row`).toBe(updatedAt); |
| 110 | + expect(expiresAtMs(fx.row(token)), `read ${i + 1} moved the minted expiry`).toBe(expiresAt); |
| 111 | + } |
| 112 | + await minted.revoke(); |
| 113 | + }); |
| 114 | + } |
| 115 | + } |
| 116 | +}); |
| 117 | + |
| 118 | +describe('[#23813] carriesPrincipalSession', () => { |
| 119 | + const ctxOf = (headers: Record<string, string>) => ({ |
| 120 | + headers: new Headers(headers), |
| 121 | + context: { authCookies: { sessionToken: { name: 'better-auth.session_token' } } }, |
| 122 | + }); |
| 123 | + const minted = `${PRINCIPAL_SESSION_TOKEN_PREFIX}abc123`; |
| 124 | + |
| 125 | + it('reads the brand on the cookie lane and on every bearer spelling', () => { |
| 126 | + expect(carriesPrincipalSession(ctxOf({ cookie: `a=b; better-auth.session_token=${minted}.sig%3D` }))).toBe(true); |
| 127 | + expect(carriesPrincipalSession(ctxOf({ authorization: `Bearer ${minted}.sig` }))).toBe(true); |
| 128 | + expect(carriesPrincipalSession(ctxOf({ authorization: `Bearer ${minted}` }))).toBe(true); |
| 129 | + expect(carriesPrincipalSession(ctxOf({ authorization: `Bearer ${encodeURIComponent(`${minted}.sig=`)}` }))).toBe(true); |
| 130 | + }); |
| 131 | + |
| 132 | + it('answers false for every other session, and for the brand under another cookie name', () => { |
| 133 | + expect(carriesPrincipalSession(ctxOf({ cookie: 'better-auth.session_token=AbC123.sig' }))).toBe(false); |
| 134 | + expect(carriesPrincipalSession(ctxOf({ authorization: 'Bearer AbC123.sig' }))).toBe(false); |
| 135 | + expect(carriesPrincipalSession(ctxOf({ cookie: `other_cookie=${minted}` }))).toBe(false); |
| 136 | + expect(carriesPrincipalSession(ctxOf({}))).toBe(false); |
| 137 | + expect(carriesPrincipalSession(undefined)).toBe(false); |
| 138 | + }); |
| 139 | +}); |
0 commit comments