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

@ -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) {