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

@ -55,11 +55,8 @@ func (a *AutheliaAdapter) AuthorizeURL(_ context.Context, req domain.AuthRequest
q.Set("response_type", "code")
q.Set("state", req.State)
q.Set("scope", "openid profile email groups")
if req.PromptLogin {
q.Set("prompt", "login")
}
if req.MaxAge != nil {
q.Set("max_age", strconv.FormatInt(int64(req.MaxAge.Seconds()), 10))
if age, ok := upstreamMaxAge(req); ok {
q.Set("max_age", strconv.FormatInt(int64(age.Seconds()), 10))
}
return base + "?" + q.Encode(), nil
@ -132,10 +129,16 @@ func (a *AutheliaAdapter) HandleCallback(ctx context.Context, params domain.Call
return nil, domain.ErrAuthFailed
}
var authTime time.Time
if at, ok := numericClaim(claims, "auth_time"); ok && at > 0 {
authTime = time.Unix(int64(at), 0).UTC()
}
// Security boundary: only the ID token claims are forwarded.
// The access_token and refresh_token remain within this adapter.
return &domain.AuthResult{
Username: username,
AuthTime: authTime,
Claims: claims,
}, nil
}
@ -190,6 +193,31 @@ func (a *AutheliaAdapter) exchangeCode(_ context.Context, code string) (*tokenRe
return &tr, nil
}
// upstreamFreshWindow is the max_age sent to Authelia in place of
// prompt=login. Authelia 4.38 registers the authorization request only after
// the user has logged in, so prompt=login is refused for every real login
// (auth_time precedes the registration); max_age tolerates that gap while
// still forcing a login for any session older than the window. KeyCape itself
// enforces the exact requirement against the returned auth_time.
const upstreamFreshWindow = 10 * time.Second
// upstreamMaxAge maps the downstream freshness requirement onto what Authelia
// can honour. prompt=login and max_age=0 both mean "authenticate now"; a
// positive max_age below the window is raised to it. The strict check is
// KeyCape's, in the callback, not Authelia's.
func upstreamMaxAge(req domain.AuthRequest) (time.Duration, bool) {
if req.PromptLogin {
return upstreamFreshWindow, true
}
if req.MaxAge == nil {
return 0, false
}
if *req.MaxAge < upstreamFreshWindow {
return upstreamFreshWindow, true
}
return *req.MaxAge, true
}
func (a *AutheliaAdapter) authorizeBaseURL() string {
if a.cfg.BrowserBaseURL != "" {
return a.cfg.BrowserBaseURL

View file

@ -453,17 +453,33 @@ func TestHandleCallback_AuthResultContainsNoRawTokens(t *testing.T) {
}
}
func TestAuthorizeURLForwardsFreshLoginRequirements(t *testing.T) {
func TestAuthorizeURLTranslatesFreshLoginToBoundedMaxAge(t *testing.T) {
// Authelia 4.38 refuses prompt=login for every real login, so freshness
// travels as max_age: prompt=login and max_age=0 become the fresh window,
// a short max_age is raised to it, and a longer one passes through.
adapter := authelia.New(testConfig(), &mockHTTPClient{})
for _, seconds := range []int{0, 60} {
age := time.Duration(seconds) * time.Second
target, err := adapter.AuthorizeURL(context.Background(), domain.AuthRequest{PromptLogin: true, MaxAge: &age})
zero, short, long := time.Duration(0), 3*time.Second, 600*time.Second
cases := []struct {
req domain.AuthRequest
want string
}{
{domain.AuthRequest{PromptLogin: true}, "10"},
{domain.AuthRequest{PromptLogin: true, MaxAge: &long}, "10"},
{domain.AuthRequest{MaxAge: &zero}, "10"},
{domain.AuthRequest{MaxAge: &short}, "10"},
{domain.AuthRequest{MaxAge: &long}, "600"},
}
for _, c := range cases {
target, err := adapter.AuthorizeURL(context.Background(), c.req)
if err != nil {
t.Fatal(err)
}
parsed, _ := url.Parse(target)
if parsed.Query().Get("prompt") != "login" || parsed.Query().Get("max_age") != fmt.Sprint(seconds) {
t.Fatalf("freshness requirements missing from provider URL: %s", target)
if parsed.Query().Has("prompt") {
t.Fatalf("prompt must not reach Authelia: %s", target)
}
if got := parsed.Query().Get("max_age"); got != c.want {
t.Fatalf("max_age = %q, want %q (%s)", got, c.want, target)
}
}
target, _ := adapter.AuthorizeURL(context.Background(), domain.AuthRequest{})
@ -472,3 +488,28 @@ func TestAuthorizeURLForwardsFreshLoginRequirements(t *testing.T) {
t.Fatal("ordinary SSO was changed")
}
}
func TestHandleCallback_CarriesUpstreamAuthTime(t *testing.T) {
authTime := time.Now().Add(-30 * time.Second).Unix()
for _, tc := range []struct {
claims map[string]interface{}
want time.Time
}{
{map[string]interface{}{"sub": "uid", "auth_time": authTime}, time.Unix(authTime, 0).UTC()},
{map[string]interface{}{"sub": "uid"}, time.Time{}},
} {
body := buildTokenResponse(tc.claims)
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return jsonResponse(body), nil
},
}
result, err := authelia.New(testConfig(), client).HandleCallback(context.Background(), domain.CallbackParams{Code: "code"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.AuthTime.Equal(tc.want) {
t.Fatalf("AuthTime = %v, want %v", result.AuthTime, tc.want)
}
}
}

View file

@ -386,8 +386,8 @@ func TestServiceClientExampleContracts(t *testing.T) {
if errs := config.ValidateConfig(cfg); len(errs) != 0 {
t.Fatalf("service client examples must validate: %v", errs)
}
if len(cfg.Clients) != 5 {
t.Fatalf("client examples: want 4 service clients and 1 human client, got %d", len(cfg.Clients))
if len(cfg.Clients) != 7 {
t.Fatalf("client examples: want 6 service clients and 1 human client, got %d", len(cfg.Clients))
}
codingAgent := cfg.Clients[0]

View file

@ -41,6 +41,9 @@ type CallbackParams struct {
// AuthResult is the normalized identity returned after successful authentication.
type AuthResult struct {
Username string
// AuthTime is when the user actually authenticated at the provider, from
// its verified auth_time claim; zero when the provider did not say.
AuthTime time.Time
// Raw identity claims from the backend (not exposed to OIDC layer directly)
Claims map[string]interface{}
}

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

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"))
}
})
}
}

View file

@ -9,7 +9,7 @@ flavor: implementation
owner: codex
topic_slug: netkingdom
created: "2026-09-12"
updated: "2026-09-12"
updated: "2026-09-23"
related: [VERGABE-WP-0019, NK-WP-0037, USER-WP-0025]
state_hub_workstream_id: "144dd430-1a09-51e1-9aad-e799ee86c338"
---
@ -69,3 +69,20 @@ identity mappings and staff accounts. Native invited-user sign-in/MFA and
confirmation are now requested from the operator; no user credential was used
by the agent. Recovery and two-user acceptance remain their existing tasks.
Evidence: railiance-apps/docs/evidence/2026-09-12-demo-company-sso-live.md.
2026-09-23 regression from the attended recipient sign-in (net-kingdom message
ee4808e2, NK-WP-0037-T02): Authelia 4.38 registers the authorization request
only after the login, so forwarding prompt=login fails every real login ("auth_time
happened before the authorization request was registered"). The 2026-09-12
forwarding checks were redirect-only and never completed a login. Fix (owner
option a): KeyCape no longer sends prompt to Authelia. prompt=login and
max_age=0 travel as max_age=10, a shorter max_age is raised to 10, a longer one
passes through. KeyCape then enforces the exact requirement itself against the
verified upstream auth_time: prompt=login needs an authentication no earlier
than the KeyCape request, max_age needs one within that age, and a missing
auth_time fails closed (event `stale_upstream_authentication`). Known edge: a
user whose Authelia login is under 10s old at request time is not re-prompted
and is refused rather than admitted. Also fixed the stale example-count test left
by 651625c/1620ce2 (6 service + 1 human). Full Go suite passes. Not deployed:
needs a release plus the attended window, then a completed rerun of the
bernd.worsch-99 journey. Redirect-only checks do not count as proof.