Skip to content

Commit aa31124

Browse files
committed
test(plugin-auth): pin the session-read failure answer and single-use reservations on 1.7.12
Two vendor changes the lift brings, measured on a real ObjectQL + SQL stack: customSession lets a failed session lookup through as 500 FAILED_TO_GET_SESSION instead of a signed-out null, and the adapter keeps the forced reservation id so a replayed reservation is refused. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9ffd7eb commit aa31124

2 files changed

Lines changed: 266 additions & 0 deletions

File tree

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25096] A session lookup the store cannot answer is a FAILURE, never a
5+
* signed-out caller.
6+
*
7+
* plugin-auth mounts better-auth's `customSession` whenever a data engine is
8+
* configured, so `GET /get-session` and every in-process `api.getSession` run
9+
* its override. Up to 1.7.7 that override caught whatever the inner session
10+
* read threw and answered `null`, so a store outage read exactly like "nobody
11+
* is signed in": over HTTP `anonymous-session-refusal.ts` then turned that
12+
* `null` into its 401 `UNAUTHENTICATED`, telling a signed-in browser to sign in
13+
* again. From 1.7.8 (better-auth/better-auth#11391) the override lets the
14+
* read's error through, so the store failure reaches the caller as
15+
* better-auth's own 500 `FAILED_TO_GET_SESSION`, which that seam passes
16+
* through untouched (it converts a `200` + `null` only).
17+
*
18+
* That is the right answer for this platform (AGENTS.md, "Absence must be
19+
* loud" and the degradation-log-level rule): a failure the caller is told
20+
* about is not a silent degradation, and the browser can tell "retry later"
21+
* from "sign in again". The in-process callers that want the old reading keep
22+
* it on purpose: `resolveSessionData` (runtime) and `resolveRequestExecutionContext`
23+
* (core) catch the throw themselves and treat the request as anonymous.
24+
*
25+
* Real stack: `ObjectQL` over `@objectstack/driver-sql` + better-sqlite3, the
26+
* objects from this plugin's manifest and a real `AuthManager`. The fault is
27+
* injected at the engine, on the `sys_session` reads alone, after the sign-up
28+
* wrote the session, so every other read the request makes is the real one.
29+
*
30+
* Pinned:
31+
* - the store failure answers 500 `FAILED_TO_GET_SESSION` over HTTP and
32+
* rejects the in-process read, and it does not clear the session cookie;
33+
* - the two controls the failure must stay distinguishable from: the healthy
34+
* store answers the session, and a token the store does not hold is signed
35+
* out (401 `UNAUTHENTICATED` over HTTP, `null` in process).
36+
*/
37+
38+
import { describe, it, expect, afterEach, vi } from 'vitest';
39+
import { ObjectQL } from '@objectstack/objectql';
40+
import { SqlDriver } from '@objectstack/driver-sql';
41+
import { AuthManager } from './auth-manager.js';
42+
import { authIdentityObjects } from './manifest.js';
43+
44+
const BASE = 'http://localhost:3000';
45+
const AUTH = `${BASE}/api/v1/auth`;
46+
const SECRET = 'test-secret-at-least-32-chars-long-25096';
47+
const PASSWORD = 'S3cure!Passw0rd-25096';
48+
const SESSION_OBJECT = 'sys_session';
49+
50+
const engines: ObjectQL[] = [];
51+
afterEach(async () => {
52+
vi.restoreAllMocks();
53+
while (engines.length) {
54+
try { await engines.pop()!.destroy(); } catch { /* noop */ }
55+
}
56+
});
57+
58+
async function bootEngine(): Promise<ObjectQL> {
59+
const engine = new ObjectQL();
60+
engines.push(engine);
61+
engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true);
62+
await engine.init();
63+
for (const object of authIdentityObjects) engine.registry.registerObject(object as never, '@objectstack/plugin-auth');
64+
await engine.syncSchemas();
65+
return engine;
66+
}
67+
68+
const cookieFrom = (response: Response): string =>
69+
(response.headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? ''])
70+
.map((c) => c.split(';')[0])
71+
.filter(Boolean)
72+
.join('; ');
73+
74+
const getSession = (manager: AuthManager, cookie: string) =>
75+
manager.handleRequest(new Request(`${AUTH}/get-session`, { headers: { cookie } }));
76+
77+
/** A signed-up user's session cookie, on a manager over a real engine. */
78+
async function signedIn(): Promise<{ engine: ObjectQL; manager: AuthManager; cookie: string }> {
79+
const engine = await bootEngine();
80+
const manager = new AuthManager({ secret: SECRET, baseUrl: BASE, dataEngine: engine as never } as never);
81+
const signedUp = await manager.handleRequest(new Request(`${AUTH}/sign-up/email`, {
82+
method: 'POST',
83+
headers: { 'Content-Type': 'application/json', origin: BASE },
84+
body: JSON.stringify({ email: 'reader@example.com', password: PASSWORD, name: 'Reader' }),
85+
}));
86+
expect(signedUp.status, await signedUp.clone().text()).toBe(200);
87+
const cookie = cookieFrom(signedUp);
88+
expect(cookie, 'the sign-up set a session cookie').toMatch(/session_token=/);
89+
return { engine, manager, cookie };
90+
}
91+
92+
/** Every engine read of `sys_session` throws from here on; every other read is the real one. */
93+
function failSessionStore(engine: ObjectQL): { attempts: () => number } {
94+
let attempts = 0;
95+
const target = engine as unknown as Record<'find' | 'findOne', (object: string, ...rest: unknown[]) => Promise<unknown>>;
96+
for (const method of ['find', 'findOne'] as const) {
97+
const real = target[method].bind(engine);
98+
vi.spyOn(target, method).mockImplementation(async (object: string, ...rest: unknown[]) => {
99+
if (object === SESSION_OBJECT) {
100+
attempts += 1;
101+
throw new Error('session store unavailable (injected)');
102+
}
103+
return real(object, ...rest);
104+
});
105+
}
106+
return { attempts: () => attempts };
107+
}
108+
109+
describe('[#25096] a session lookup the store cannot answer is a failure, never a signed-out caller', () => {
110+
it('CONTROL: the healthy store answers the signed-in session', async () => {
111+
const { manager, cookie } = await signedIn();
112+
const res = await getSession(manager, cookie);
113+
expect(res.status).toBe(200);
114+
const body = (await res.json()) as { user?: { email?: string } } | null;
115+
expect(body?.user?.email).toBe('reader@example.com');
116+
});
117+
118+
it('CONTROL: a token the store does not hold is signed out, 401 UNAUTHENTICATED over HTTP and null in process', async () => {
119+
const { manager } = await signedIn();
120+
const stranger = 'better-auth.session_token=not-a-session-the-store-holds.sig';
121+
const res = await getSession(manager, stranger);
122+
const text = await res.clone().text();
123+
expect(res.status, text).toBe(401);
124+
expect(JSON.stringify(JSON.parse(text))).toContain('UNAUTHENTICATED');
125+
const api = await manager.getApi();
126+
expect(await api.getSession({ headers: new Headers({ cookie: stranger }) } as never)).toBeNull();
127+
});
128+
129+
it('over HTTP: the store failure answers 500 FAILED_TO_GET_SESSION and keeps the session cookie', async () => {
130+
vi.spyOn(console, 'error').mockImplementation(() => {});
131+
const { engine, manager, cookie } = await signedIn();
132+
const fault = failSessionStore(engine);
133+
134+
const res = await getSession(manager, cookie);
135+
136+
expect(fault.attempts(), 'the request really reached the session store').toBeGreaterThan(0);
137+
const text = await res.clone().text();
138+
expect(res.status, text).toBe(500);
139+
expect((JSON.parse(text) as { code?: string }).code).toBe('FAILED_TO_GET_SESSION');
140+
// Nothing tells the browser to drop its credential: the session still
141+
// exists, the store just could not read it.
142+
const cleared = (res.headers.getSetCookie?.() ?? []).filter((c) => /session_token=;|session_token=\s*;|Max-Age=0/i.test(c));
143+
expect(cleared).toEqual([]);
144+
});
145+
146+
it('in process: the store failure rejects `api.getSession` instead of resolving null', async () => {
147+
vi.spyOn(console, 'error').mockImplementation(() => {});
148+
const { engine, manager, cookie } = await signedIn();
149+
const api = await manager.getApi();
150+
const fault = failSessionStore(engine);
151+
152+
const read = api.getSession({ headers: new Headers({ cookie }) } as never);
153+
154+
await expect(read).rejects.toMatchObject({ statusCode: 500, body: { code: 'FAILED_TO_GET_SESSION' } });
155+
expect(fault.attempts()).toBeGreaterThan(0);
156+
});
157+
});
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25096] The ObjectQL adapter keeps the primary key better-auth forces on a
5+
* single-use reservation, so a replayed reservation is refused.
6+
*
7+
* better-auth's replay checks (DPoP proof `jti`s, `private_key_jwt` client
8+
* assertions, SAML assertion ids, SIWE nonces) all go through
9+
* `internalAdapter.reserveVerificationValue`. It inserts a `verification` row
10+
* whose `id` is derived from the guarded value (SHA-256 of
11+
* `reserve:IDENTIFIER`), with `forceAllowId: true`, and reads a failed insert
12+
* as "already taken" only when a row with THAT id exists. So the whole check
13+
* rests on one property of the adapter underneath: the forced id must land as
14+
* the row's primary key. An adapter or engine that swaps in an id of its own
15+
* lets every insert succeed, and every replay with it.
16+
*
17+
* 1.7.8 (better-auth/better-auth#11625) fixed that for `generateId: "uuid"`,
18+
* where the id field used to replace the non-UUID digest. plugin-auth sets no
19+
* `generateId`, so better-auth's own generator runs and the forced digest is
20+
* passed through as base64url (`encodeDeterministicId`). What this file pins is
21+
* the half better-auth cannot see: that the ObjectQL adapter, the engine and
22+
* the SQL driver keep it.
23+
*
24+
* Real stack: `ObjectQL` over `@objectstack/driver-sql` + better-sqlite3, the
25+
* objects from this plugin's manifest and a real `AuthManager`; the
26+
* reservation is better-auth's own, read off the instance's `$context`.
27+
*/
28+
29+
import { describe, it, expect, afterEach } from 'vitest';
30+
import { createHash } from 'node:crypto';
31+
import { ObjectQL } from '@objectstack/objectql';
32+
import { SqlDriver } from '@objectstack/driver-sql';
33+
import { AuthManager } from './auth-manager.js';
34+
import { authIdentityObjects } from './manifest.js';
35+
36+
const BASE = 'http://localhost:3000';
37+
const SECRET = 'test-secret-at-least-32-chars-long-25096';
38+
const VERIFICATION_OBJECT = 'sys_verification';
39+
40+
type Row = Record<string, unknown>;
41+
42+
interface ReservingContext {
43+
internalAdapter: {
44+
reserveVerificationValue(data: { identifier: string; value: string; expiresAt: Date }): Promise<boolean>;
45+
};
46+
}
47+
48+
const engines: ObjectQL[] = [];
49+
afterEach(async () => {
50+
while (engines.length) {
51+
try { await engines.pop()!.destroy(); } catch { /* noop */ }
52+
}
53+
});
54+
55+
async function boot(): Promise<{ engine: ObjectQL; context: ReservingContext }> {
56+
const engine = new ObjectQL();
57+
engines.push(engine);
58+
engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true);
59+
await engine.init();
60+
for (const object of authIdentityObjects) engine.registry.registerObject(object as never, '@objectstack/plugin-auth');
61+
await engine.syncSchemas();
62+
const manager = new AuthManager({ secret: SECRET, baseUrl: BASE, dataEngine: engine as never } as never);
63+
const auth = (await manager.getAuthInstance()) as unknown as { $context: Promise<ReservingContext> };
64+
return { engine, context: await auth.$context };
65+
}
66+
67+
/** Rows at DRIVER level, below the engine, with the identifier the reservation stored. */
68+
async function reservationRows(engine: ObjectQL, identifier: string): Promise<Row[]> {
69+
const driver = (engine as unknown as { getDriver(o: string): { find(o: string, q: unknown): Promise<unknown> } })
70+
.getDriver(VERIFICATION_OBJECT);
71+
const found = await driver.find(VERIFICATION_OBJECT, { where: { identifier } });
72+
return (Array.isArray(found) ? found : [found]).filter(Boolean) as Row[];
73+
}
74+
75+
/** better-auth's deterministic reservation id without a `generateId` strategy: base64url SHA-256, unpadded. */
76+
const reservationId = (identifier: string): string =>
77+
createHash('sha256').update(`reserve:${identifier}`).digest('base64url');
78+
79+
const expiresAt = () => new Date(Date.now() + 5 * 60_000);
80+
81+
describe('[#25096] a single-use reservation refuses its replay through the ObjectQL adapter', () => {
82+
it('the first reservation wins and is stored under the id better-auth forced', async () => {
83+
const { engine, context } = await boot();
84+
const identifier = 'replay-probe:first';
85+
86+
expect(await context.internalAdapter.reserveVerificationValue({ identifier, value: 'v', expiresAt: expiresAt() })).toBe(true);
87+
88+
const rows = await reservationRows(engine, identifier);
89+
expect(rows).toHaveLength(1);
90+
expect(rows[0]!.id, 'the engine kept the forced primary key').toBe(reservationId(identifier));
91+
});
92+
93+
it('the replay of the same value is refused, and leaves one row', async () => {
94+
const { engine, context } = await boot();
95+
const identifier = 'replay-probe:replayed';
96+
97+
expect(await context.internalAdapter.reserveVerificationValue({ identifier, value: 'v', expiresAt: expiresAt() })).toBe(true);
98+
expect(await context.internalAdapter.reserveVerificationValue({ identifier, value: 'v', expiresAt: expiresAt() })).toBe(false);
99+
100+
expect(await reservationRows(engine, identifier)).toHaveLength(1);
101+
});
102+
103+
it('CONTROL: a different value is its own reservation and wins', async () => {
104+
const { context } = await boot();
105+
106+
expect(await context.internalAdapter.reserveVerificationValue({ identifier: 'replay-probe:a', value: 'v', expiresAt: expiresAt() })).toBe(true);
107+
expect(await context.internalAdapter.reserveVerificationValue({ identifier: 'replay-probe:b', value: 'v', expiresAt: expiresAt() })).toBe(true);
108+
});
109+
});

0 commit comments

Comments
 (0)