@@ -84,11 +84,22 @@ public OidcControllerTests()
8484 /// configures the session store mock to accept <c>TryAdvanceToLoginCompletedAsync</c>.
8585 /// Call before any <c>CompleteLogin</c> test that should get past session enforcement.
8686 /// </summary>
87- private void SetupPreAuthSession ( )
87+ private void SetupPreAuthSession ( bool isStepUp = false , string stateCode = CoStateKey )
8888 {
8989 _controller . ControllerContext . HttpContext = new DefaultHttpContext ( ) ;
9090 _controller . ControllerContext . HttpContext . Request . Headers . Cookie =
9191 $ "{ OidcSessionCookie . CookieName } ={ TestSessionId } ";
92+ _sessionStore . GetAsync ( TestSessionId , Arg . Any < CancellationToken > ( ) )
93+ . Returns ( new PreAuthSession
94+ {
95+ Id = TestSessionId ,
96+ State = "test-state" ,
97+ CodeVerifier = "test-verifier" ,
98+ StateCode = stateCode ,
99+ RedirectUri = "http://localhost:3000/callback" ,
100+ IsStepUp = isStepUp ,
101+ Phase = PreAuthSessionPhase . CallbackCompleted
102+ } ) ;
92103 _sessionStore . TryAdvanceToLoginCompletedAsync (
93104 TestSessionId , Arg . Any < string > ( ) , Arg . Any < CancellationToken > ( ) )
94105 . Returns ( true ) ;
@@ -304,7 +315,7 @@ public async Task CompleteLogin_WhenValidCallbackToken_SetsAuthCookieAndReturnsE
304315 [ Fact ]
305316 public async Task CompleteLogin_WhenStepUpAndNoExistingUser_Returns400 ( )
306317 {
307- SetupPreAuthSession ( ) ;
318+ SetupPreAuthSession ( isStepUp : true ) ;
308319 const string signingKey = "complete-login-signing-key-at-least-32-characters-long" ;
309320 _config [ "Oidc:CompleteLoginSigningKey" ] . Returns ( signingKey ) ;
310321
@@ -331,7 +342,7 @@ public async Task CompleteLogin_WhenStepUpAndNoExistingUser_Returns400()
331342 [ Fact ]
332343 public async Task CompleteLogin_WhenStepUpAndSafeReturnUrl_Returns200WithReturnUrl ( )
333344 {
334- SetupPreAuthSession ( ) ;
345+ SetupPreAuthSession ( isStepUp : true ) ;
335346 const string signingKey = "complete-login-signing-key-at-least-32-characters-long" ;
336347 _config [ "Oidc:CompleteLoginSigningKey" ] . Returns ( signingKey ) ;
337348
@@ -365,7 +376,7 @@ public async Task CompleteLogin_WhenStepUpAndSafeReturnUrl_Returns200WithReturnU
365376 [ Fact ]
366377 public async Task CompleteLogin_WhenStepUpAndExternalReturnUrl_OmitsReturnUrlFromResponse ( )
367378 {
368- SetupPreAuthSession ( ) ;
379+ SetupPreAuthSession ( isStepUp : true ) ;
369380 const string signingKey = "complete-login-signing-key-at-least-32-characters-long" ;
370381 _config [ "Oidc:CompleteLoginSigningKey" ] . Returns ( signingKey ) ;
371382
@@ -392,6 +403,126 @@ public async Task CompleteLogin_WhenStepUpAndExternalReturnUrl_OmitsReturnUrlFro
392403 Assert . Null ( response . ReturnUrl ) ;
393404 }
394405
406+ /// <summary>
407+ /// CompleteLogin must reject requests where the body's stateCode does not match the
408+ /// session's stored stateCode, even if both are in the allowlist. This prevents a
409+ /// tenant-switching attack where a session created for one state is used with another.
410+ /// </summary>
411+ [ Fact ]
412+ public async Task CompleteLogin_WhenBodyStateCodeDiffersFromSession_Returns400 ( )
413+ {
414+ // Session was created for "co", but body says "co" — we need a second state in
415+ // the allowlist to test mismatch. Create a controller with both "co" and "dc".
416+ var multiStateAllowlist = new StateAllowlist ( [ "co" , "dc" ] ) ;
417+ var jwtSettings = Options . Create ( new JwtSettings
418+ {
419+ SecretKey = new string ( 'x' , 32 ) ,
420+ Issuer = "test" ,
421+ Audience = "test" ,
422+ ExpirationMinutes = 60
423+ } ) ;
424+ var env = Substitute . For < IWebHostEnvironment > ( ) ;
425+ env . EnvironmentName . Returns ( "Development" ) ;
426+ var sessionStore = Substitute . For < IPreAuthSessionStore > ( ) ;
427+ var controller = new OidcController (
428+ _config ,
429+ NullLogger < OidcController > . Instance ,
430+ _userRepository ,
431+ _jwtService ,
432+ jwtSettings ,
433+ multiStateAllowlist ,
434+ sessionStore ,
435+ env )
436+ {
437+ ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext ( ) }
438+ } ;
439+
440+ // Set oidc_session cookie
441+ controller . ControllerContext . HttpContext . Request . Headers . Cookie =
442+ $ "{ OidcSessionCookie . CookieName } ={ TestSessionId } ";
443+
444+ // Session was created for "co"
445+ sessionStore . GetAsync ( TestSessionId , Arg . Any < CancellationToken > ( ) )
446+ . Returns ( new PreAuthSession
447+ {
448+ Id = TestSessionId ,
449+ State = "test-state" ,
450+ CodeVerifier = "test-verifier" ,
451+ StateCode = "co" ,
452+ RedirectUri = "http://localhost:3000/callback" ,
453+ IsStepUp = false ,
454+ Phase = PreAuthSessionPhase . CallbackCompleted
455+ } ) ;
456+ sessionStore . TryAdvanceToLoginCompletedAsync (
457+ TestSessionId , Arg . Any < string > ( ) , Arg . Any < CancellationToken > ( ) )
458+ . Returns ( true ) ;
459+
460+ const string signingKey = "complete-login-signing-key-at-least-32-characters-long" ;
461+ _config [ "Oidc:CompleteLoginSigningKey" ] . Returns ( signingKey ) ;
462+ var callbackToken = CreateValidCallbackToken ( signingKey , email : "user@example.com" ) ;
463+
464+ // Body says "dc" but session says "co" — should be rejected
465+ var body = new CompleteLoginRequest ( "dc" , callbackToken ) ;
466+
467+ var result = await controller . CompleteLogin ( body , CancellationToken . None ) ;
468+
469+ var badRequest = Assert . IsType < BadRequestObjectResult > ( result ) ;
470+ var error = Assert . IsType < ErrorResponse > ( badRequest . Value ) ;
471+ Assert . Contains ( "mismatch" , error . Error , StringComparison . OrdinalIgnoreCase ) ;
472+ }
473+
474+ /// <summary>
475+ /// CompleteLogin must use the session's IsStepUp value, not the body's. A client
476+ /// should not be able to initiate a non-step-up flow and then send isStepUp=true
477+ /// on complete-login to trigger the IAL1+ upgrade path.
478+ /// </summary>
479+ [ Fact ]
480+ public async Task CompleteLogin_WhenBodyIsStepUpDiffersFromSession_UsesSessionValue ( )
481+ {
482+ // Set oidc_session cookie
483+ _controller . ControllerContext . HttpContext = new DefaultHttpContext ( ) ;
484+ _controller . ControllerContext . HttpContext . Request . Headers . Cookie =
485+ $ "{ OidcSessionCookie . CookieName } ={ TestSessionId } ";
486+
487+ // Session was created as non-step-up
488+ _sessionStore . GetAsync ( TestSessionId , Arg . Any < CancellationToken > ( ) )
489+ . Returns ( new PreAuthSession
490+ {
491+ Id = TestSessionId ,
492+ State = "test-state" ,
493+ CodeVerifier = "test-verifier" ,
494+ StateCode = CoStateKey ,
495+ RedirectUri = "http://localhost:3000/callback" ,
496+ IsStepUp = false ,
497+ Phase = PreAuthSessionPhase . CallbackCompleted
498+ } ) ;
499+ _sessionStore . TryAdvanceToLoginCompletedAsync (
500+ TestSessionId , Arg . Any < string > ( ) , Arg . Any < CancellationToken > ( ) )
501+ . Returns ( true ) ;
502+
503+ const string signingKey = "complete-login-signing-key-at-least-32-characters-long" ;
504+ _config [ "Oidc:CompleteLoginSigningKey" ] . Returns ( signingKey ) ;
505+ var callbackToken = CreateValidCallbackToken ( signingKey , email : "user@example.com" ) ;
506+
507+ // Body lies: says IsStepUp=true, but session says false
508+ var body = new CompleteLoginRequest ( CoStateKey , callbackToken , IsStepUp : true ) ;
509+
510+ var user = new User { Id = 1 , Email = "user@example.com" } ;
511+ _userRepository . GetOrCreateUserAsync ( Arg . Any < string > ( ) , Arg . Any < CancellationToken > ( ) )
512+ . Returns ( ( user , false ) ) ;
513+ _jwtService . GenerateToken ( Arg . Any < User > ( ) , Arg . Any < IReadOnlyDictionary < string , string > ? > ( ) )
514+ . Returns ( "portal-jwt" ) ;
515+
516+ var result = await _controller . CompleteLogin ( body , CancellationToken . None ) ;
517+
518+ // Should succeed (non-step-up path), NOT the step-up path
519+ Assert . IsType < OkObjectResult > ( result ) ;
520+
521+ // The non-step-up path calls GetOrCreateUserAsync, NOT GetUserByEmailAsync
522+ await _userRepository . Received ( 1 ) . GetOrCreateUserAsync ( Arg . Any < string > ( ) , Arg . Any < CancellationToken > ( ) ) ;
523+ await _userRepository . DidNotReceive ( ) . GetUserByEmailAsync ( Arg . Any < string > ( ) , Arg . Any < CancellationToken > ( ) ) ;
524+ }
525+
395526 /// <summary>Callback token issuer/audience must match <c>Oidc:CallbackRedirectUri</c> (trimmed).</summary>
396527 private const string TestCallbackTokenAudience = "http://localhost:3000/callback" ;
397528
0 commit comments