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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue