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
4379import { describe , it , expect , vi , beforeAll , afterAll } from 'vitest' ;
@@ -54,6 +90,8 @@ const ORIGIN = 'http://localhost:3000';
5490const BASE = '/api/v1/auth' ;
5591const ADMIN_EMAIL = 'admin.22258@example.com' ;
5692const 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. */
5896const 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+
266434describe ( '[#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