@@ -20,8 +20,9 @@ import {
2020 serializeSessions ,
2121 type SessionEntry ,
2222} from '@/modules/auth/session/cookie' ;
23- import { ProviderError } from '@/modules/auth/types' ;
23+ import { ProviderError , type Session } from '@/modules/auth/types' ;
2424import { decideAuthorize } from '@/resources/authorize/authorize-decision' ;
25+ import { primaryFresh } from '@/resources/shared/lifetimes' ;
2526import { logAuthEvent } from '@/server/observability' ;
2627import { type AuthErrorCode , providerErrorCode } from '@/utils/errors/auth-error' ;
2728import { redirect } from 'react-router' ;
@@ -41,6 +42,13 @@ const DEAD_SESSION_CODES: ReadonlySet<ProviderError['code']> = new Set([
4142 'PERMISSION_DENIED' ,
4243] ) ;
4344
45+ // Freshness window for honoring a query-handed-back sessionId against a prompt=login request.
46+ // The post-auth finalize redirect is near-immediate (the ceremony hands the just-authenticated
47+ // session id straight back to /authorize), so 2 minutes generously covers redirect latency +
48+ // clock skew while keeping forced re-auth meaningful — a STALE session id forged onto the URL
49+ // can no longer satisfy prompt=login.
50+ const FRESH_LOGIN_WINDOW_MS = 2 * 60 * 1000 ; // 2 minutes
51+
4452/**
4553 * A typed description of what the /authorize loader resolved to. The route (and the
4654 * `outcomeToResponse` translator below) turn this into a redirect/JSON Response. Keeping
@@ -118,8 +126,9 @@ function errorRedirect(url: URL, code: AuthErrorCode): Response {
118126/**
119127 * Validate that a cookie session is still alive before reusing it in createCallback.
120128 *
121- * Returns an AuthorizeOutcome (caller must return it immediately) for the two non-alive
122- * outcomes, and `null` only when the session is ALIVE (caller proceeds to createCallback):
129+ * Returns an AuthorizeOutcome (which carries a `kind`; caller must return it immediately) for the
130+ * two non-alive outcomes, and `{ session }` ONLY when the session is ALIVE (caller proceeds to the
131+ * freshness gate / createCallback). Callers discriminate on `'kind' in result`:
123132 *
124133 * • CONFIRMED DEAD — getSession → null, or a ProviderError with a DEAD_SESSION_CODES code:
125134 * drop the stale entry from the cookie and re-prompt /login (self-heal; mirrors the SAML
@@ -131,6 +140,9 @@ function errorRedirect(url: URL, code: AuthErrorCode): Response {
131140 * `oidc_callback` failure WITH the code so the transient is diagnosable. A Zitadel hiccup
132141 * must never silently re-login a valid user, and must never be swallowed.
133142 *
143+ * • ALIVE — return the live `Session` so the caller can inspect its factors (the prompt=login
144+ * freshness gate needs `factors.*.verifiedAt`) before reusing it in createCallback.
145+ *
134146 * Distinguishing dead vs transient by the error code is the critical precision that prevents
135147 * introducing a new bug (logging out a valid user on a transient blip).
136148 */
@@ -140,7 +152,7 @@ async function healIfSessionDead(
140152 entry : SessionEntry ,
141153 requestId : string ,
142154 rawId : string
143- ) : Promise < AuthorizeOutcome | null > {
155+ ) : Promise < AuthorizeOutcome | { session : Session } > {
144156 let alive : Awaited < ReturnType < AuthProvider [ 'getSession' ] > > ;
145157 try {
146158 alive = await provider . getSession ( entry . id , entry . token ) ;
@@ -160,7 +172,7 @@ async function healIfSessionDead(
160172 return { kind : 'error-redirect' , code : providerErrorCode ( code ) } ;
161173 }
162174 if ( ! alive ) return healStaleEntry ( list , entry , requestId , rawId ) ; // confirmed dead
163- return null ; // alive → proceed to createCallback
175+ return { session : alive } ; // alive → caller proceeds to the freshness gate / createCallback
164176}
165177
166178/** Drop the stale entry, re-prompt /login, and emit a traceable session_stale event. */
@@ -265,7 +277,8 @@ async function resolveOidc(
265277 provider : AuthProvider ,
266278 request : Request ,
267279 url : URL ,
268- requestId : string
280+ requestId : string ,
281+ nowMs : number = Date . now ( )
269282) : Promise < AuthorizeOutcome > {
270283 const rawId = requestId . replace ( 'oidc_' , '' ) ;
271284
@@ -288,9 +301,21 @@ async function resolveOidc(
288301 if ( entry ) {
289302 // Validate liveness BEFORE reuse: a stale post-logout cookie self-heals to /login here
290303 // instead of reaching createCallback on a terminated session (→ ALREADY_DONE → /error).
291- const healed = await healIfSessionDead ( provider , list , entry , requestId , rawId ) ;
292- if ( healed ) return healed ;
293- return runCallback ( provider , rawId , entry ) ;
304+ const gate = await healIfSessionDead ( provider , list , entry , requestId , rawId ) ;
305+ if ( 'kind' in gate ) return gate ; // dead/transient → outcome already decided
306+
307+ // ANTI-FORGERY FRESHNESS GATE (prompt=login only). The sessionId is query-supplied, so a
308+ // caller can forge `&sessionId=<their_own_STALE_live_session>` onto a prompt=login request
309+ // to skip the forced re-authentication the AS explicitly demanded. A handed-back session may
310+ // satisfy prompt=login ONLY if it was genuinely freshly authenticated THIS ceremony (a
311+ // primary factor verified within FRESH_LOGIN_WINDOW_MS). If it is stale, fall through to
312+ // decideAuthorize, which routes prompt=login → /login (forced re-auth). select_account is
313+ // harmless (picking your own live session), so the gate is scoped to prompt=login.
314+ const mustReauth =
315+ authRequest . prompt . includes ( 'login' ) &&
316+ ! primaryFresh ( gate . session . factors , nowMs , FRESH_LOGIN_WINDOW_MS ) ;
317+ if ( ! mustReauth ) return runCallback ( provider , rawId , entry ) ;
318+ // else: stale prompt=login → do NOT finalize; fall through to decideAuthorize below.
294319 }
295320 }
296321
@@ -304,9 +329,11 @@ async function resolveOidc(
304329 if ( decision . target === 'callback' ) {
305330 const entry = byId ( list , decision . params ?. sessionId ?? '' ) ;
306331 if ( ! entry ) return { kind : 'error-redirect' , code : 'no_session' } ;
307- // Validate liveness BEFORE reuse (same self-heal as the explicit-sessionId path above).
332+ // Validate liveness BEFORE reuse (same self-heal as the explicit-sessionId path above). No
333+ // freshness gate here: for prompt=login decideAuthorize returns target '/login', never
334+ // 'callback', so this branch is unreachable under prompt=login (only none/default reuse).
308335 const healed = await healIfSessionDead ( provider , list , entry , requestId , rawId ) ;
309- if ( healed ) return healed ;
336+ if ( 'kind' in healed ) return healed ;
310337 return runCallback ( provider , rawId , entry ) ;
311338 }
312339 if ( decision . target === 'error' ) {
@@ -332,7 +359,8 @@ async function resolveOidc(
332359 */
333360export async function resolveAuthorize (
334361 provider : AuthProvider ,
335- request : Request
362+ request : Request ,
363+ nowMs : number = Date . now ( )
336364) : Promise < AuthorizeOutcome > {
337365 const url = new URL ( request . url ) ;
338366 const requestId = normalizeRequestId ( url ) ;
@@ -344,5 +372,5 @@ export async function resolveAuthorize(
344372
345373 if ( requestId . startsWith ( 'saml_' ) ) return resolveSaml ( provider , request , requestId ) ;
346374 if ( requestId . startsWith ( 'device_' ) ) return resolveDevice ( requestId ) ;
347- return resolveOidc ( provider , request , url , requestId ) ;
375+ return resolveOidc ( provider , request , url , requestId , nowMs ) ;
348376}
0 commit comments