Enforce login freshness in KeyCape instead of forwarding prompt=login (KEY-WP-0033).
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:
parent
9cb950752f
commit
11ce29af8b
7 changed files with 201 additions and 14 deletions
|
|
@ -33,6 +33,9 @@ type PendingState struct {
|
|||
TenantHint string
|
||||
MaxAge *time.Duration
|
||||
PromptLogin bool
|
||||
// CreatedAt is when KeyCape received the authorization request; a
|
||||
// prompt=login request needs an upstream authentication after it.
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// pendingStateStore is a thread-safe map of state → PendingState.
|
||||
|
|
@ -237,6 +240,7 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
|
|||
TenantHint: tenantHint,
|
||||
MaxAge: maxAge,
|
||||
PromptLogin: promptLogin,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||
}
|
||||
h.pending.Store(state, ps)
|
||||
|
|
@ -321,6 +325,23 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||
return
|
||||
}
|
||||
|
||||
// Freshness is enforced here, against the provider's verified auth_time,
|
||||
// rather than delegated: the provider is asked only for a bounded max_age
|
||||
// (see the Authelia adapter), which cannot express prompt=login exactly.
|
||||
if !upstreamAuthenticationFresh(ps, result.AuthTime, time.Now()) {
|
||||
h.Emitter.Emit(ctx, telemetry.Event{
|
||||
Timestamp: time.Now(),
|
||||
EventType: telemetry.EventAuthFailure,
|
||||
ClientID: ps.ClientID,
|
||||
Endpoint: "/authorize/callback",
|
||||
Result: "failure",
|
||||
ErrorType: "stale_upstream_authentication",
|
||||
})
|
||||
h.pending.Delete(state)
|
||||
h.authenticationFailure(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
existingLogin := h.Logins.fromRequest(r)
|
||||
decision, err := h.decideAssurance(ctx, ps, result.Username, existingLogin)
|
||||
if err != nil {
|
||||
|
|
@ -759,6 +780,27 @@ var unknownUserTemplate = template.Must(template.New("unknown-user").Parse(`<!do
|
|||
</body>
|
||||
</html>`))
|
||||
|
||||
// upstreamAuthenticationFresh reports whether the provider's authentication
|
||||
// satisfies the request's prompt=login / max_age. It fails closed when
|
||||
// freshness was requested and the provider gave no auth_time. auth_time has
|
||||
// whole-second precision, so bounds are compared at that precision.
|
||||
func upstreamAuthenticationFresh(ps *PendingState, authTime, now time.Time) bool {
|
||||
mustReauth := ps.PromptLogin || (ps.MaxAge != nil && *ps.MaxAge == 0)
|
||||
if !mustReauth && ps.MaxAge == nil {
|
||||
return true
|
||||
}
|
||||
if authTime.IsZero() {
|
||||
return false
|
||||
}
|
||||
if mustReauth && authTime.Before(ps.CreatedAt.Truncate(time.Second)) {
|
||||
return false
|
||||
}
|
||||
if ps.MaxAge != nil && *ps.MaxAge > 0 && now.Sub(authTime) > *ps.MaxAge+time.Second {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parsePrompt(raw string) (create, login bool) {
|
||||
for _, part := range strings.Fields(raw) {
|
||||
switch strings.ToLower(part) {
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue