Enforce login freshness in KeyCape instead of forwarding prompt=login (KEY-WP-0033).
All checks were successful
Authentication acceptance / acceptance (push) Successful in 1m27s
Authentication acceptance / provider-contract (push) Successful in 14s
Build and Publish Container Image / build-and-push (push) Successful in 52s

Authelia 4.38 refuses prompt=login for every real login because it registers
the authorization request after authentication. Send a bounded max_age
upstream and check the verified upstream auth_time against prompt=login /
max_age in the callback, failing closed when auth_time is missing.

Also update the service-client example count left stale by 651625c/1620ce2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 352750@bnt-lap001
Assistant-Session: de41ef1c-2113-4dd2-9b92-f318ffa7f98b
This commit is contained in:
tegwick 2026-09-23 21:59:21 +02:00
parent 9cb950752f
commit 11ce29af8b
7 changed files with 201 additions and 14 deletions

View file

@ -892,3 +892,59 @@ func TestFreshLoginRequirementsReachProvider(t *testing.T) {
}
}
}
// Freshness is KeyCape's to enforce: the provider is asked only for a bounded
// max_age, so the callback must refuse an upstream authentication that
// predates a prompt=login request or exceeds max_age, and must fail closed
// when freshness was requested but no auth_time came back.
func TestCallbackEnforcesUpstreamFreshness(t *testing.T) {
now := time.Now()
zero, minute := time.Duration(0), time.Minute
cases := []struct {
name string
prompt bool
maxAge *time.Duration
created time.Time
authTime time.Time
ok bool
}{
{"prompt login after request", true, nil, now.Add(-5 * time.Second), now.Add(-2 * time.Second), true},
{"prompt login same second as request", true, nil, now.Truncate(time.Second).Add(700 * time.Millisecond), now.Truncate(time.Second), true},
{"prompt login reused session", true, nil, now.Add(-5 * time.Second), now.Add(-time.Hour), false},
{"prompt login without auth_time", true, nil, now.Add(-5 * time.Second), time.Time{}, false},
{"max_age zero is prompt login", false, &zero, now.Add(-5 * time.Second), now.Add(-time.Hour), false},
{"max_age satisfied", false, &minute, now.Add(-5 * time.Second), now.Add(-30 * time.Second), true},
{"max_age exceeded", false, &minute, now.Add(-5 * time.Second), now.Add(-5 * time.Minute), false},
{"no freshness requested", false, nil, now, time.Time{}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
auth := &mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice", AuthTime: tc.authTime}}
h := &oidc.AuthorizeHandler{
ClientConfig: testClient(),
Auth: auth,
MFA: &mockMFAProvider{},
Sessions: oidc.NewSessionStore(),
Emitter: &captureEmitter{},
}
h.PendingStates().Store("fresh-state", &oidc.PendingState{
ClientID: "test-client",
RedirectURI: "https://app.example.com/callback",
PKCEChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
PKCEChallengeMethod: "S256",
State: "fresh-state",
Scopes: []string{"openid"},
PromptLogin: tc.prompt,
MaxAge: tc.maxAge,
CreatedAt: tc.created,
ExpiresAt: now.Add(5 * time.Minute),
})
w := httptest.NewRecorder()
h.ServeHTTPCallback(w, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=c&state=fresh-state", nil))
issued := w.Code == http.StatusFound && strings.Contains(w.Header().Get("Location"), "code=")
if issued != tc.ok {
t.Fatalf("code issued = %v, want %v (status %d, location %q)", issued, tc.ok, w.Code, w.Header().Get("Location"))
}
})
}
}