Don't backdate a fresh MFA to a stale kc_login cookie (INFD-IN-0005)
All checks were successful
Authentication acceptance / acceptance (push) Successful in 1m38s
Authentication acceptance / provider-contract (push) Successful in 17s
Build and Publish Container Image / build-and-push (push) Successful in 53s

completeAuthorization reused an existing kc_login session's IssuedAt as
authTime whenever one existed for the same user, even at the two call
sites that had just validated an MFA token in this exact request. A
platform-root sign-in that stepped up with fresh MFA while an older
browser session cookie was still around got that older timestamp
stamped onto the new authentication, understating freshness for every
downstream binding-grade check (flex-auth's 900s review window denied
informed-decision's infd-20260927-b01 approval as a result).

Add a freshMFA parameter so only the carried-forward-assurance call
site (no MFA challenge in this request) reuses the prior session's
IssuedAt; the two call sites following a successful
MFA.ValidateMFAToken always stamp authTime to now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 169987@bnt-lap001
Assistant-Session: 322ef1ef-9048-4021-8570-b6d6f6347999
This commit is contained in:
tegwick 2026-09-27 19:03:06 +02:00
parent 8352f4bd37
commit 911e9de969
3 changed files with 134 additions and 8 deletions

View file

@ -616,6 +616,61 @@ func TestAuthorizeCallback_MFASubmission_ValidToken_RedirectsWithCode(t *testing
}
}
// INFD-IN-0005: a browser that already carried an older kc_login cookie for
// the same user must not have that stale IssuedAt stamped onto a session
// that just passed a fresh MFA challenge. Downstream freshness checks (e.g.
// flex-auth's 900-second review window) read AuthTime as proof of when MFA
// actually happened; silently backdating it made real, just-completed MFA
// look stale.
func TestAuthorizeCallback_MFASubmission_FreshEvenWithStalePriorLoginCookie(t *testing.T) {
mfa := &mockMFAProvider{required: true}
emitter := &captureEmitter{}
sessions := oidc.NewSessionStore()
logins := oidc.NewLoginSessionStore()
stale := logins.Create("alice", domain.AssuranceAAL2)
stale.IssuedAt = time.Now().Add(-4 * time.Hour)
h := &oidc.AuthorizeHandler{
ClientConfig: testClient(),
Auth: &mockAuthProvider{},
MFA: mfa,
Sessions: sessions,
Logins: logins,
Emitter: emitter,
}
h.PendingStates().Store("random-state", &oidc.PendingState{
ClientID: "test-client",
RedirectURI: "https://app.example.com/callback",
PKCEChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
PKCEChallengeMethod: "S256",
State: "random-state",
Scopes: []string{"openid"},
ExpiresAt: time.Now().Add(5 * time.Minute),
AuthenticatedUser: "alice",
})
form := url.Values{"state": {"random-state"}, "mfa_token": {"123456"}}
req := httptest.NewRequest(http.MethodPost, "/authorize/callback", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: "kc_login", Value: stale.ID})
w := httptest.NewRecorder()
h.ServeHTTPCallback(w, req)
if w.Code != http.StatusFound {
t.Fatalf("expected 302 redirect, got %d (body: %s)", w.Code, w.Body.String())
}
loc, _ := url.Parse(w.Header().Get("Location"))
sess, ok := sessions.Get(loc.Query().Get("code"))
if !ok {
t.Fatal("expected a PKCE session for the issued code")
}
if time.Since(sess.AuthTime) > time.Minute {
t.Errorf("AuthTime = %v, want close to now: a fresh MFA submission must not inherit the stale kc_login cookie's IssuedAt", sess.AuthTime)
}
}
func TestAuthorizeCallback_MFANotRequired_SessionRecordsMFAVerifiedFalse(t *testing.T) {
auth := &mockAuthProvider{
callbackResult: &domain.AuthResult{Username: "alice"},