diff --git a/src/internal/adapters/authelia/adapter.go b/src/internal/adapters/authelia/adapter.go index a1c9248..62388c7 100644 --- a/src/internal/adapters/authelia/adapter.go +++ b/src/internal/adapters/authelia/adapter.go @@ -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 diff --git a/src/internal/adapters/authelia/adapter_test.go b/src/internal/adapters/authelia/adapter_test.go index 7ad37b2..ef9c46d 100644 --- a/src/internal/adapters/authelia/adapter_test.go +++ b/src/internal/adapters/authelia/adapter_test.go @@ -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) + } + } +} diff --git a/src/internal/config/config_test.go b/src/internal/config/config_test.go index 6916c6a..0dc8bff 100644 --- a/src/internal/config/config_test.go +++ b/src/internal/config/config_test.go @@ -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] diff --git a/src/internal/domain/auth.go b/src/internal/domain/auth.go index 4863a4f..66c907c 100644 --- a/src/internal/domain/auth.go +++ b/src/internal/domain/auth.go @@ -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{} } diff --git a/src/internal/server/oidc/authorize.go b/src/internal/server/oidc/authorize.go index b07772e..44c70ed 100644 --- a/src/internal/server/oidc/authorize.go +++ b/src/internal/server/oidc/authorize.go @@ -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(` `)) +// 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) { diff --git a/src/internal/server/oidc/authorize_test.go b/src/internal/server/oidc/authorize_test.go index cad3b26..0031da6 100644 --- a/src/internal/server/oidc/authorize_test.go +++ b/src/internal/server/oidc/authorize_test.go @@ -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")) + } + }) + } +} diff --git a/workplans/KEY-WP-0033-vergabe-fresh-login.md b/workplans/KEY-WP-0033-vergabe-fresh-login.md index 5ad58dd..640fb27 100644 --- a/workplans/KEY-WP-0033-vergabe-fresh-login.md +++ b/workplans/KEY-WP-0033-vergabe-fresh-login.md @@ -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.