|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#22258] This plugin's admin doors read the session in-process without |
| 5 | + * leaving the browser's cookie behind. |
| 6 | + * |
| 7 | + * better-auth's `getSession` renews a session older than `updateAge` — it |
| 8 | + * moves `sys_session.expires_at` to `now + expiresIn` — and stages the renewed |
| 9 | + * cookie on THAT call's response. The raw `/admin/*` mounts below read the |
| 10 | + * session in-process and answer with their own response, so the renewed |
| 11 | + * cookie was thrown away: the session lived on as a bearer while the browser's |
| 12 | + * cookie died at its old `Max-Age` (a SPLIT session). Each mount now hands |
| 13 | + * better-auth `inProcessSessionReadInput(headers)` (`@objectstack/types`): a |
| 14 | + * request carrying a session cookie reads without renewal; a bearer-only |
| 15 | + * request renews as before. |
| 16 | + * |
| 17 | + * Pinned against REAL better-auth — the installed version, its `expiresIn` / |
| 18 | + * `updateAge` read off the live instance — behind the plugin's REAL route |
| 19 | + * registration on a real Hono app (the `admin-remove-user-gate-ordering` |
| 20 | + * harness: a real `AuthManager` over the shared in-memory engine). The session |
| 21 | + * is aged to `now + expiresIn − updateAge − 60 s`, past `updateAge`, and its |
| 22 | + * `sys_session` row is read straight off the engine's table after each request: |
| 23 | + * |
| 24 | + * - each door, by cookie: `expires_at` does not move and no session cookie |
| 25 | + * is set — cookie and session stay aligned; |
| 26 | + * - the same door, bearer only: `expires_at` renews to `now + expiresIn`, |
| 27 | + * and no cookie is set on a response to a request that sent none (this |
| 28 | + * half is also each door's positive control — it proves the door's reader |
| 29 | + * ran); |
| 30 | + * - the control, `GET /get-session`: renews AND re-issues the cookie with |
| 31 | + * `Max-Age = expiresIn`. |
| 32 | + * |
| 33 | + * Doors and the reader each one reaches (`auth-plugin.ts`): |
| 34 | + * `POST /admin/oauth2/toggle-disabled` → its own platform-admin gate read |
| 35 | + * `POST /admin/set-user-manager` → `gateAdmin`, the shared gate of every |
| 36 | + * `/admin/*` mount that calls it |
| 37 | + * `POST /admin/unlock-user` → its own platform-admin gate read |
| 38 | + * `POST /admin/has-permission` → its own platform-admin branch read |
| 39 | + * Each body is one the door answers right after its read, so no second session |
| 40 | + * read follows the one under test. |
| 41 | + */ |
| 42 | + |
| 43 | +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; |
| 44 | +import { Hono } from 'hono'; |
| 45 | +import { AuthManager } from './auth-manager'; |
| 46 | +import { AuthPlugin } from './auth-plugin'; |
| 47 | +import { createMemoryEngine } from './impersonation-bearer-rotation.test'; |
| 48 | +import { inviteForAudienceGate } from './audience-gate-test-support'; |
| 49 | +import type { PluginContext } from '@objectstack/core'; |
| 50 | + |
| 51 | +const SECRET = 'test-secret-at-least-32-chars-long!!'; |
| 52 | +const PASSWORD = 'S3cure!Passw0rd-22258'; |
| 53 | +const ORIGIN = 'http://localhost:3000'; |
| 54 | +const BASE = '/api/v1/auth'; |
| 55 | +const ADMIN_EMAIL = 'admin.22258@example.com'; |
| 56 | +const MEMBER_EMAIL = 'member.22258@example.com'; |
| 57 | +/** Clock slack between the server's `now` and this file's, in ms. */ |
| 58 | +const SLACK_MS = 5_000; |
| 59 | + |
| 60 | +const mockCtx = (): PluginContext => |
| 61 | + ({ |
| 62 | + registerService: vi.fn(), |
| 63 | + getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)), |
| 64 | + getServices: vi.fn(() => new Map()), |
| 65 | + hook: vi.fn(), |
| 66 | + trigger: vi.fn(), |
| 67 | + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, |
| 68 | + getKernel: vi.fn(), |
| 69 | + }) as any; |
| 70 | + |
| 71 | +let engine: ReturnType<typeof createMemoryEngine>; |
| 72 | +let manager: AuthManager; |
| 73 | +let app: Hono; |
| 74 | +let expiresInSec: number; |
| 75 | +let updateAgeSec: number; |
| 76 | +let memberId: string; |
| 77 | +/** The admin's credentials, as a browser and as a bearer client hold them. */ |
| 78 | +let cookiePair: string; |
| 79 | +let bearer: string; |
| 80 | +let sessionToken: string; |
| 81 | + |
| 82 | +/** The admin's `sys_session` row, read straight off the engine's table. */ |
| 83 | +function sessionRow(): Record<string, unknown> { |
| 84 | + const row = ((engine.tables.get('sys_session') ?? []) as any[]).find((r) => r.token === sessionToken); |
| 85 | + if (!row) throw new Error('pin: the signed-in session row is gone'); |
| 86 | + return row; |
| 87 | +} |
| 88 | + |
| 89 | +const storedExpiry = (): number => new Date(sessionRow().expires_at as any).getTime(); |
| 90 | + |
| 91 | +/** Age the session to just past `updateAge` — the card's `now + expiresIn − updateAge − 60 s`. */ |
| 92 | +function ageSession(): number { |
| 93 | + const target = Date.now() + (expiresInSec - updateAgeSec - 60) * 1000; |
| 94 | + const row = sessionRow(); |
| 95 | + row.expires_at = typeof row.expires_at === 'string' ? new Date(target).toISOString() : new Date(target); |
| 96 | + const stored = storedExpiry(); |
| 97 | + expect(Math.abs(stored - target), 'the aging write did not land').toBeLessThan(1_000); |
| 98 | + return stored; |
| 99 | +} |
| 100 | + |
| 101 | +/** The session-token cookie a response stages, or `null`. */ |
| 102 | +function sessionCookieOf(res: Response): { maxAgeSec: number | null } | null { |
| 103 | + const staged = res.headers.getSetCookie().find((c) => /(?:^|\.)session_token=/.test(c.split(';')[0])); |
| 104 | + if (!staged) return null; |
| 105 | + const m = /;\s*max-age=(\d+)/i.exec(staged); |
| 106 | + return { maxAgeSec: m ? Number(m[1]) : null }; |
| 107 | +} |
| 108 | + |
| 109 | +const asCookie = (): Record<string, string> => ({ cookie: cookiePair }); |
| 110 | +const asBearer = (): Record<string, string> => ({ authorization: `Bearer ${bearer}` }); |
| 111 | + |
| 112 | +const fire = (path: string, body: unknown, credential: Record<string, string>) => |
| 113 | + app.request(`${ORIGIN}${BASE}${path}`, { |
| 114 | + method: 'POST', |
| 115 | + headers: { 'content-type': 'application/json', origin: ORIGIN, ...credential }, |
| 116 | + body: JSON.stringify(body), |
| 117 | + }); |
| 118 | + |
| 119 | +/** |
| 120 | + * The pin: cookie and session expiry stay ALIGNED — either the session did not |
| 121 | + * move and no cookie was staged, or it moved and its cookie was re-issued to |
| 122 | + * expire with it. |
| 123 | + */ |
| 124 | +function expectAligned(label: string, aged: number, after: number, res: Response) { |
| 125 | + const cookie = sessionCookieOf(res); |
| 126 | + if (after !== aged) { |
| 127 | + expect(cookie, `${label}: the session renewed (+${Math.round((after - aged) / 1000)} s) but its cookie was not re-issued`).not.toBeNull(); |
| 128 | + const cookieExpiry = Date.now() + (cookie!.maxAgeSec ?? 0) * 1000; |
| 129 | + expect(Math.abs(cookieExpiry - after), `${label}: re-issued cookie and session expire apart`).toBeLessThan(SLACK_MS); |
| 130 | + } else { |
| 131 | + expect(cookie, `${label}: a cookie was staged for a session that did not move`).toBeNull(); |
| 132 | + } |
| 133 | +} |
| 134 | + |
| 135 | +beforeAll(async () => { |
| 136 | + vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 137 | + vi.spyOn(console, 'error').mockImplementation(() => {}); |
| 138 | + |
| 139 | + engine = createMemoryEngine(); |
| 140 | + manager = new AuthManager({ |
| 141 | + secret: SECRET, |
| 142 | + baseUrl: ORIGIN, |
| 143 | + dataEngine: engine, |
| 144 | + plugins: { admin: true }, |
| 145 | + } as any); |
| 146 | + |
| 147 | + const direct = (path: string, body: unknown) => |
| 148 | + manager.handleRequest( |
| 149 | + new Request(`${ORIGIN}${BASE}${path}`, { |
| 150 | + method: 'POST', |
| 151 | + headers: { 'Content-Type': 'application/json' }, |
| 152 | + body: JSON.stringify(body), |
| 153 | + }), |
| 154 | + ); |
| 155 | + |
| 156 | + for (const [email, name] of [ |
| 157 | + [ADMIN_EMAIL, 'Platform Admin'], |
| 158 | + [MEMBER_EMAIL, 'Plain Member'], |
| 159 | + ]) { |
| 160 | + // The default audience posture is invite_only: fixture users beyond the |
| 161 | + // first enter through the invitation carve-out (audience-gate-test-support). |
| 162 | + await inviteForAudienceGate(manager, email); |
| 163 | + const res = await direct('/sign-up/email', { email, password: PASSWORD, name }); |
| 164 | + expect(res.status, `sign-up ${email}: ${await res.clone().text()}`).toBe(200); |
| 165 | + } |
| 166 | + |
| 167 | + const users = (engine.tables.get('sys_user') ?? []) as any[]; |
| 168 | + memberId = String(users.find((r) => r.email === MEMBER_EMAIL)!.id); |
| 169 | + // The legacy scalar `isPlatformAdminUser` accepts as its documented |
| 170 | + // back-compat signal — every door below admits this caller. |
| 171 | + users.find((r) => r.email === ADMIN_EMAIL)!.role = 'admin'; |
| 172 | + |
| 173 | + // The version this package pins, read off the running instance — never assumed. |
| 174 | + const authContext: any = await manager.getAuthContext(); |
| 175 | + expiresInSec = Number(authContext.sessionConfig.expiresIn); |
| 176 | + updateAgeSec = Number(authContext.sessionConfig.updateAge); |
| 177 | + |
| 178 | + const res = await direct('/sign-in/email', { email: ADMIN_EMAIL, password: PASSWORD }); |
| 179 | + expect(res.status, `sign-in: ${await res.clone().text()}`).toBe(200); |
| 180 | + const staged = res.headers.getSetCookie().find((c) => /(?:^|\.)session_token=/.test(c.split(';')[0])); |
| 181 | + if (!staged) throw new Error('pin sign-in staged no session cookie'); |
| 182 | + cookiePair = staged.split(';')[0]; |
| 183 | + bearer = String(res.headers.get('set-auth-token') ?? ''); |
| 184 | + if (!bearer) throw new Error('pin sign-in emitted no set-auth-token'); |
| 185 | + sessionToken = String(((await res.json()) as any).token); |
| 186 | + |
| 187 | + // The REAL route registration — raw mounts ahead of the catch-all — on a |
| 188 | + // real Hono app in front of the real AuthManager. |
| 189 | + app = new Hono(); |
| 190 | + const ctx = mockCtx(); |
| 191 | + const plugin = new AuthPlugin({ secret: SECRET }); |
| 192 | + await plugin.init(ctx); |
| 193 | + (plugin as any).authManager = manager; |
| 194 | + (plugin as any).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx); |
| 195 | +}); |
| 196 | + |
| 197 | +afterAll(() => vi.restoreAllMocks()); |
| 198 | + |
| 199 | +describe('[#22258] precondition — this stack renews, and the defect is better-auth\'s own behaviour', () => { |
| 200 | + it('reads expiresIn / updateAge off the running better-auth', () => { |
| 201 | + expect(expiresInSec).toBeGreaterThan(updateAgeSec); |
| 202 | + expect(updateAgeSec).toBeGreaterThan(60); |
| 203 | + }); |
| 204 | + |
| 205 | + it('a bare in-process getSession on an aged session renews it and stages a cookie nobody sends', async () => { |
| 206 | + const aged = ageSession(); |
| 207 | + const api: any = await manager.getApi(); |
| 208 | + // No rule: the call every reader in this file used to make. |
| 209 | + await api.getSession({ headers: new Headers({ cookie: cookiePair }) }); |
| 210 | + const after = storedExpiry(); |
| 211 | + expect(after - aged, 'the fixture does not renew — every pin below would pass vacuously') |
| 212 | + .toBeGreaterThan((updateAgeSec - SLACK_MS / 1000) * 1000); |
| 213 | + }); |
| 214 | +}); |
| 215 | + |
| 216 | +const DOORS: Array<{ label: string; path: string; body: () => unknown }> = [ |
| 217 | + { |
| 218 | + label: 'POST /admin/oauth2/toggle-disabled', |
| 219 | + path: '/admin/oauth2/toggle-disabled', |
| 220 | + body: () => ({ client_id: 'cl_pin_22258_absent', disabled: true }), |
| 221 | + }, |
| 222 | + { |
| 223 | + label: 'POST /admin/set-user-manager (gateAdmin)', |
| 224 | + path: '/admin/set-user-manager', |
| 225 | + body: () => ({}), |
| 226 | + }, |
| 227 | + { |
| 228 | + label: 'POST /admin/unlock-user', |
| 229 | + path: '/admin/unlock-user', |
| 230 | + body: () => ({ userId: memberId }), |
| 231 | + }, |
| 232 | + { |
| 233 | + label: 'POST /admin/has-permission (platform-admin branch)', |
| 234 | + path: '/admin/has-permission', |
| 235 | + body: () => ({ permissions: { user: ['list'] } }), |
| 236 | + }, |
| 237 | +]; |
| 238 | + |
| 239 | +describe('[#22258] each admin door leaves cookie and session expiry aligned', () => { |
| 240 | + for (const door of DOORS) { |
| 241 | + it(`${door.label} — by cookie: no renewal, no cookie`, async () => { |
| 242 | + const aged = ageSession(); |
| 243 | + const res = await fire(door.path, door.body(), asCookie()); |
| 244 | + // Admitted: the read under test resolved the admin (a refusal would be 401/403). |
| 245 | + expect([401, 403], `${door.label} refused the admin: ${res.status}`).not.toContain(res.status); |
| 246 | + expect(res.status, `${door.label} answered ${res.status}`).toBeLessThan(500); |
| 247 | + const after = storedExpiry(); |
| 248 | + expectAligned(door.label, aged, after, res); |
| 249 | + expect(after, `${door.label}: a cookie request renewed in-process`).toBe(aged); |
| 250 | + }); |
| 251 | + |
| 252 | + it(`${door.label} — bearer only: renews as before, sets no cookie`, async () => { |
| 253 | + const aged = ageSession(); |
| 254 | + const res = await fire(door.path, door.body(), asBearer()); |
| 255 | + expect([401, 403], `${door.label} refused the admin: ${res.status}`).not.toContain(res.status); |
| 256 | + expect(res.status, `${door.label} answered ${res.status}`).toBeLessThan(500); |
| 257 | + const after = storedExpiry(); |
| 258 | + expect(after, `${door.label}: a bearer-only read no longer renews`).toBeGreaterThan(aged); |
| 259 | + expect(Math.abs(after - (Date.now() + expiresInSec * 1000)), `${door.label}: the renewal is not to now + expiresIn`) |
| 260 | + .toBeLessThan(SLACK_MS); |
| 261 | + expect(sessionCookieOf(res), `${door.label}: a cookie was set on a response to a request that sent none`).toBeNull(); |
| 262 | + }); |
| 263 | + } |
| 264 | +}); |
| 265 | + |
| 266 | +describe('[#22258] control — the browser-facing get-session still renews and re-issues', () => { |
| 267 | + it('renews an aged session and re-issues the cookie with Max-Age = expiresIn', async () => { |
| 268 | + const aged = ageSession(); |
| 269 | + const res = await app.request(`${ORIGIN}${BASE}/get-session`, { headers: { origin: ORIGIN, ...asCookie() } }); |
| 270 | + expect(res.status).toBe(200); |
| 271 | + const after = storedExpiry(); |
| 272 | + expect(Math.abs(after - (Date.now() + expiresInSec * 1000)), 'get-session did not renew').toBeLessThan(SLACK_MS); |
| 273 | + expect(sessionCookieOf(res)?.maxAgeSec, 'get-session did not re-issue the cookie with Max-Age = expiresIn').toBe(expiresInSec); |
| 274 | + expectAligned('get-session', aged, after, res); |
| 275 | + }); |
| 276 | +}); |
0 commit comments