Skip to content

Commit b9b04ef

Browse files
committed
test(auth): pin the re-dispatch and vendor-call doors against real better-auth
Nine door shapes (the SSO register and domain-verification bridges, the send-verification-email wrapper with and without an email, add-member, set-initial-password and the OAuth self-service register), each by cookie past updateAge (expires_at unchanged, no cookie) and bearer-only (renews to now + expiresIn, no cookie), on the existing real AuthManager + registerAuthRoutes harness. Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent d0d6bb8 commit b9b04ef

1 file changed

Lines changed: 169 additions & 1 deletion

File tree

‎packages/plugins/plugin-auth/src/in-process-session-renewal.pin.test.ts‎

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,42 @@
3838
* `POST /admin/has-permission` → its own platform-admin branch read
3939
* Each body is one the door answers right after its read, so no second session
4040
* read follows the one under test.
41+
*
42+
* ## [#22398] The doors whose in-process read is not a `getSession` call
43+
*
44+
* The same split happened at doors that read the session through better-auth
45+
* by another route, so the `getSession` rule could not reach them:
46+
*
47+
* - a RE-DISPATCH through the better-auth handler — a `/get-session` lookup
48+
* whose response the door keeps only the JSON of, or a bridge's forward to a
49+
* better-auth route behind `sessionMiddleware` whose status and body are all
50+
* it keeps. Each now carries `disableRefresh` in its URL for a cookie
51+
* request (`in-process-redispatch.ts`);
52+
* - an in-process VENDOR ENDPOINT call carrying the request's headers, whose
53+
* session middleware reads the session and stages the renewed cookie on a
54+
* response that is dropped. Each now takes `inProcessSessionReadInput`'s
55+
* input, which better-auth's `getSessionFromCtx` spreads into that read.
56+
*
57+
* Doors and the in-process reads each one makes after the mount's own gate:
58+
* `POST /admin/sso/register` → `/get-session` re-dispatch, then the
59+
* inner `/sso/register` (its ADR-0135
60+
* D6 before-hook reads the actor)
61+
* `POST /admin/sso/register-saml` → the same pair, SAML bridge
62+
* `POST /admin/sso/request-domain-verification`,
63+
* `POST /admin/sso/verify-domain` → the inner route (`sessionMiddleware`)
64+
* `POST /send-verification-email` → `/get-session` re-dispatch (no email
65+
* in the body), then the inner route
66+
* `POST /organization/add-member` → `authApi.addMember`
67+
* `POST /set-initial-password` → `authApi.setPassword`
68+
* `POST /sys-oauth-application/register` → `authApi.createOAuthClient`
69+
* Each answer below is the one the door gives right after the LAST of its reads
70+
* ran, so it proves every read under test happened (the comment on
71+
* `RE_DISPATCH_AND_VENDOR_DOORS` says how).
72+
*
73+
* The fixture turns on what those reads need — SSO with domain verification,
74+
* the OIDC provider, email verification — and nothing reaches the network: the
75+
* SSO and domain-verification answers are refusals the vendor gives before any
76+
* discovery fetch or DNS lookup.
4177
*/
4278

4379
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
@@ -54,6 +90,8 @@ const ORIGIN = 'http://localhost:3000';
5490
const BASE = '/api/v1/auth';
5591
const ADMIN_EMAIL = 'admin.22258@example.com';
5692
const MEMBER_EMAIL = 'member.22258@example.com';
93+
/** [#22398] An SSO provider the MEMBER registered — the admin may not manage it. */
94+
const MEMBER_IDP = 'pin-22398-member-idp';
5795
/** Clock slack between the server's `now` and this file's, in ms. */
5896
const SLACK_MS = 5_000;
5997

@@ -141,7 +179,9 @@ beforeAll(async () => {
141179
secret: SECRET,
142180
baseUrl: ORIGIN,
143181
dataEngine: engine,
144-
plugins: { admin: true },
182+
// [#22398] The #22398 doors reach their in-process reads only with these on.
183+
plugins: { admin: true, sso: true, ssoDomainVerification: true, oidcProvider: true },
184+
emailVerification: {},
145185
} as any);
146186

147187
const direct = (path: string, body: unknown) =>
@@ -169,6 +209,20 @@ beforeAll(async () => {
169209
// The legacy scalar `isPlatformAdminUser` accepts as its documented
170210
// back-compat signal — every door below admits this caller.
171211
users.find((r) => r.email === ADMIN_EMAIL)!.role = 'admin';
212+
// [#22398] A verified address: `/send-verification-email` then answers 400
213+
// EMAIL_ALREADY_VERIFIED right after its session read, with no transport.
214+
users.find((r) => r.email === ADMIN_EMAIL)!.email_verified = true;
215+
// [#22398] Org-less and the member's: `checkProviderAccess` refuses the admin
216+
// 403 right after `sessionMiddleware` read the session.
217+
await engine.insert('sys_sso_provider', {
218+
id: 'ssop_pin_22398',
219+
provider_id: MEMBER_IDP,
220+
issuer: 'https://idp.pin-22398.example.com',
221+
domain: 'pin-22398.example.com',
222+
user_id: memberId,
223+
organization_id: null,
224+
domain_verified: false,
225+
});
172226

173227
// The version this package pins, read off the running instance — never assumed.
174228
const authContext: any = await manager.getAuthContext();
@@ -263,6 +317,120 @@ describe('[#22258] each admin door leaves cookie and session expiry aligned', ()
263317
}
264318
});
265319

320+
/** The error code a door answered with: the envelope's, or better-auth's native body's. */
321+
const codeOf = (json: any): string | undefined => json?.error?.code ?? json?.code;
322+
323+
/**
324+
* [#22398] Each door, and the answer that proves its last in-process read ran:
325+
*
326+
* - `/admin/sso/register(-saml)`: 403 with the bridge's own code. The inner
327+
* `/sso/register` before-hook resolved the actor and refused it (the
328+
* legacy `role` admits at the mount's gate, not at the ADR-0068 D4 hook) —
329+
* an unresolved session would have been the vendor's 401;
330+
* - the domain-verification bridges: 403 — `checkProviderAccess` refused a
331+
* provider the admin does not own, after `sessionMiddleware` resolved the
332+
* admin;
333+
* - `/send-verification-email`: better-auth's own 400 EMAIL_ALREADY_VERIFIED,
334+
* which needs the session's user (with no email in the body, that email
335+
* came from the `/get-session` re-dispatch);
336+
* - add-member: the vendor's 400 ORGANIZATION_NOT_FOUND, read after its
337+
* session; set-initial-password: 409 PASSWORD_ALREADY_SET, read after
338+
* `sensitiveSessionMiddleware`; the OAuth register: 200, the client minted
339+
* for the session's user.
340+
*/
341+
const RE_DISPATCH_AND_VENDOR_DOORS: Array<{
342+
label: string;
343+
path: string;
344+
body: () => unknown;
345+
answers: { status: number; code?: string };
346+
}> = [
347+
{
348+
label: 'POST /admin/sso/register (get-session re-dispatch + inner /sso/register)',
349+
path: '/admin/sso/register',
350+
body: () => ({ providerId: 'pin-22398-oidc', issuer: 'https://idp.pin-22398.example.com', domain: 'pin-22398.example.com', clientId: 'cid', clientSecret: 'csecret' }),
351+
answers: { status: 403, code: 'SSO_REGISTER_FAILED' },
352+
},
353+
{
354+
label: 'POST /admin/sso/register-saml (get-session re-dispatch + inner /sso/register)',
355+
path: '/admin/sso/register-saml',
356+
body: () => ({ providerId: 'pin-22398-saml', issuer: 'https://idp.pin-22398.example.com', domain: 'pin-22398.example.com', entryPoint: 'https://idp.pin-22398.example.com/sso', cert: 'MIIBpin22398' }),
357+
answers: { status: 403, code: 'SAML_REGISTER_FAILED' },
358+
},
359+
{
360+
label: 'POST /admin/sso/request-domain-verification (inner re-dispatch)',
361+
path: '/admin/sso/request-domain-verification',
362+
body: () => ({ providerId: MEMBER_IDP }),
363+
answers: { status: 403 },
364+
},
365+
{
366+
label: 'POST /admin/sso/verify-domain (inner re-dispatch)',
367+
path: '/admin/sso/verify-domain',
368+
body: () => ({ providerId: MEMBER_IDP }),
369+
answers: { status: 403 },
370+
},
371+
{
372+
label: 'POST /send-verification-email, no email (get-session re-dispatch + inner route)',
373+
path: '/send-verification-email',
374+
body: () => ({}),
375+
answers: { status: 400, code: 'EMAIL_ALREADY_VERIFIED' },
376+
},
377+
{
378+
label: 'POST /send-verification-email, explicit email (inner route)',
379+
path: '/send-verification-email',
380+
body: () => ({ email: ADMIN_EMAIL }),
381+
answers: { status: 400, code: 'EMAIL_ALREADY_VERIFIED' },
382+
},
383+
{
384+
label: 'POST /organization/add-member (authApi.addMember)',
385+
path: '/organization/add-member',
386+
body: () => ({ userId: memberId, role: 'member', organizationId: 'org_pin_22398_absent' }),
387+
answers: { status: 400, code: 'ORGANIZATION_NOT_FOUND' },
388+
},
389+
{
390+
label: 'POST /set-initial-password (authApi.setPassword)',
391+
path: '/set-initial-password',
392+
body: () => ({ newPassword: 'Another!Passw0rd-22398' }),
393+
answers: { status: 409, code: 'PASSWORD_ALREADY_SET' },
394+
},
395+
{
396+
label: 'POST /sys-oauth-application/register (authApi.createOAuthClient)',
397+
path: '/sys-oauth-application/register',
398+
body: () => ({ name: 'pin-22398', redirectURLs: 'https://app.pin-22398.example.com/callback' }),
399+
answers: { status: 200 },
400+
},
401+
];
402+
403+
/** The door gave the answer that proves its last in-process read ran. */
404+
async function expectAnswered(door: (typeof RE_DISPATCH_AND_VENDOR_DOORS)[number], res: Response) {
405+
const json: any = await res.clone().json().catch(() => null);
406+
expect(res.status, `${door.label} answered ${res.status}: ${JSON.stringify(json)}`).toBe(door.answers.status);
407+
if (door.answers.code) expect(codeOf(json), `${door.label}: ${JSON.stringify(json)}`).toBe(door.answers.code);
408+
}
409+
410+
describe('[#22398] each re-dispatch and vendor-call door leaves cookie and session expiry aligned', () => {
411+
for (const door of RE_DISPATCH_AND_VENDOR_DOORS) {
412+
it(`${door.label} — by cookie: no renewal, no cookie`, async () => {
413+
const aged = ageSession();
414+
const res = await fire(door.path, door.body(), asCookie());
415+
await expectAnswered(door, res);
416+
const after = storedExpiry();
417+
expectAligned(door.label, aged, after, res);
418+
expect(after, `${door.label}: a cookie request renewed in-process`).toBe(aged);
419+
});
420+
421+
it(`${door.label} — bearer only: renews as before, sets no cookie`, async () => {
422+
const aged = ageSession();
423+
const res = await fire(door.path, door.body(), asBearer());
424+
await expectAnswered(door, res);
425+
const after = storedExpiry();
426+
expect(after, `${door.label}: a bearer-only read no longer renews`).toBeGreaterThan(aged);
427+
expect(Math.abs(after - (Date.now() + expiresInSec * 1000)), `${door.label}: the renewal is not to now + expiresIn`)
428+
.toBeLessThan(SLACK_MS);
429+
expect(sessionCookieOf(res), `${door.label}: a cookie was set on a response to a request that sent none`).toBeNull();
430+
});
431+
}
432+
});
433+
266434
describe('[#22258] control — the browser-facing get-session still renews and re-issues', () => {
267435
it('renews an aged session and re-issues the cookie with Max-Age = expiresIn', async () => {
268436
const aged = ageSession();

0 commit comments

Comments
 (0)