Skip to content

Commit 9abc9e5

Browse files
James Blairclaude
andcommitted
fix: bind CompleteLogin to session's IsStepUp and StateCode instead of request body
CompleteLogin previously read IsStepUp and StateCode from the untrusted request body. A client could initiate a non-step-up OIDC flow and then send isStepUp=true on complete-login to trigger the IAL1+ upgrade path, or switch tenant by sending a different stateCode. Now CompleteLogin retrieves the pre-auth session via GetAsync and uses session.IsStepUp and session.StateCode for all downstream logic, matching the pattern already used in the Callback endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7657341 commit 9abc9e5

2 files changed

Lines changed: 154 additions & 7 deletions

File tree

src/SEBT.Portal.Api/Controllers/Auth/OidcController.cs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,22 @@ public async Task<IActionResult> CompleteLogin(
233233
return StatusCode(StatusCodes.Status403Forbidden, new ErrorResponse("Missing pre-auth session."));
234234
}
235235

236+
// --- Retrieve session to cross-check stateCode and read IsStepUp ---
237+
var session = await sessionStore.GetAsync(sessionId, cancellationToken);
238+
if (session == null)
239+
{
240+
logger.LogWarning("OIDC CompleteLogin rejected: session not found (reason=missing_session, SessionId={SessionId})", sessionId);
241+
return StatusCode(StatusCodes.Status403Forbidden, new ErrorResponse("Pre-auth session invalid, expired, or already used."));
242+
}
243+
244+
// --- Validate stateCode matches stored value (prevents tenant switching) ---
245+
if (!string.Equals(requestedStateCode, session.StateCode, StringComparison.OrdinalIgnoreCase))
246+
{
247+
logger.LogWarning(
248+
"OIDC CompleteLogin rejected: stateCode mismatch (reason=mismatched_stateCode, SessionId={SessionId})", sessionId);
249+
return BadRequest(new ErrorResponse("State code mismatch."));
250+
}
251+
236252
// --- Verify the callback token matches this session and hasn't been consumed ---
237253
var tokenHash = IPreAuthSessionStore.HashCallbackToken(callbackToken);
238254
var advanced = await sessionStore.TryAdvanceToLoginCompletedAsync(sessionId, tokenHash, cancellationToken);
@@ -248,7 +264,7 @@ public async Task<IActionResult> CompleteLogin(
248264
OidcSessionCookie.Clear(Response);
249265
await sessionStore.RemoveAsync(sessionId, cancellationToken);
250266

251-
var stateKey = requestedStateCode.ToLowerInvariant();
267+
var stateKey = session.StateCode.ToLowerInvariant();
252268
var signingKey = config["Oidc:CompleteLoginSigningKey"];
253269
if (string.IsNullOrEmpty(signingKey))
254270
{
@@ -314,7 +330,7 @@ public async Task<IActionResult> CompleteLogin(
314330
var normalizedEmail = EmailNormalizer.Normalize(email);
315331
User user;
316332

317-
if (body.IsStepUp)
333+
if (session.IsStepUp)
318334
{
319335
var existingUser = await userRepository.GetUserByEmailAsync(normalizedEmail, cancellationToken);
320336
if (existingUser == null)
@@ -356,7 +372,7 @@ public async Task<IActionResult> CompleteLogin(
356372
AuthCookies.SetAuthCookie(Response, token, expiresAt);
357373

358374
string? safeReturnUrl = null;
359-
if (body.IsStepUp)
375+
if (session.IsStepUp)
360376
{
361377
safeReturnUrl = TrySanitizeStepUpReturnUrl(body.ReturnUrl);
362378
if (safeReturnUrl == null && !string.IsNullOrWhiteSpace(body.ReturnUrl))

test/SEBT.Portal.Tests/Unit/Controllers/OidcControllerTests.cs

Lines changed: 135 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)