From 8d4336e9448c564ac85f5f10dc75fff3c553564c Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 12 Sep 2026 02:43:31 +0200 Subject: [PATCH] Forward fresh-login requirements to the authentication provider Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c --- src/internal/adapters/authelia/adapter.go | 7 +++ .../adapters/authelia/adapter_test.go | 20 ++++++++ src/internal/config/config_test.go | 4 +- src/internal/domain/auth.go | 4 ++ src/internal/server/oidc/authorize.go | 2 + src/internal/server/oidc/authorize_test.go | 23 +++++++++- workplans/KEY-WP-0033-vergabe-fresh-login.md | 46 +++++++++++++++++++ 7 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 workplans/KEY-WP-0033-vergabe-fresh-login.md diff --git a/src/internal/adapters/authelia/adapter.go b/src/internal/adapters/authelia/adapter.go index be2ffef..a1c9248 100644 --- a/src/internal/adapters/authelia/adapter.go +++ b/src/internal/adapters/authelia/adapter.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "time" @@ -54,6 +55,12 @@ 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)) + } return base + "?" + q.Encode(), nil } diff --git a/src/internal/adapters/authelia/adapter_test.go b/src/internal/adapters/authelia/adapter_test.go index 644e509..7ad37b2 100644 --- a/src/internal/adapters/authelia/adapter_test.go +++ b/src/internal/adapters/authelia/adapter_test.go @@ -452,3 +452,23 @@ func TestHandleCallback_AuthResultContainsNoRawTokens(t *testing.T) { t.Error("AuthResult.Claims must not expose raw access_token — security boundary violation") } } + +func TestAuthorizeURLForwardsFreshLoginRequirements(t *testing.T) { + 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}) + 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) + } + } + target, _ := adapter.AuthorizeURL(context.Background(), domain.AuthRequest{}) + parsed, _ := url.Parse(target) + if parsed.Query().Has("prompt") || parsed.Query().Has("max_age") { + t.Fatal("ordinary SSO was changed") + } +} diff --git a/src/internal/config/config_test.go b/src/internal/config/config_test.go index 3c28665..6916c6a 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) != 4 { - t.Fatalf("service client examples: want 4, got %d", len(cfg.Clients)) + if len(cfg.Clients) != 5 { + t.Fatalf("client examples: want 4 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 534124e..4863a4f 100644 --- a/src/internal/domain/auth.go +++ b/src/internal/domain/auth.go @@ -3,6 +3,7 @@ package domain import ( "context" "errors" + "time" ) // AuthProvider handles login UI delegation and session management. @@ -18,6 +19,9 @@ type AuthProvider interface { // AuthRequest contains the parameters for initiating an auth flow. type AuthRequest struct { + // Forward interactive freshness requirements to the actual login provider. + PromptLogin bool + MaxAge *time.Duration ClientID string RedirectURI string State string diff --git a/src/internal/server/oidc/authorize.go b/src/internal/server/oidc/authorize.go index 322cdce..cea8cc1 100644 --- a/src/internal/server/oidc/authorize.go +++ b/src/internal/server/oidc/authorize.go @@ -246,6 +246,8 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request // Delegate to Auth provider. authURL, err := h.Auth.AuthorizeURL(ctx, domain.AuthRequest{ + PromptLogin: promptLogin, + MaxAge: maxAge, ClientID: clientID, RedirectURI: redirectURI, State: state, diff --git a/src/internal/server/oidc/authorize_test.go b/src/internal/server/oidc/authorize_test.go index 6900645..cad3b26 100644 --- a/src/internal/server/oidc/authorize_test.go +++ b/src/internal/server/oidc/authorize_test.go @@ -22,6 +22,7 @@ import ( // mockAuthProvider implements domain.AuthProvider. type mockAuthProvider struct { + request domain.AuthRequest authorizeURL string authorizeErr error @@ -29,7 +30,8 @@ type mockAuthProvider struct { callbackErr error } -func (m *mockAuthProvider) AuthorizeURL(_ context.Context, _ domain.AuthRequest) (string, error) { +func (m *mockAuthProvider) AuthorizeURL(_ context.Context, req domain.AuthRequest) (string, error) { + m.request = req if m.authorizeErr != nil { return "", m.authorizeErr } @@ -871,3 +873,22 @@ func TestAuthorizeHandler_ServeHTTP_DispatchesToCallback(t *testing.T) { } } } + +func TestFreshLoginRequirementsReachProvider(t *testing.T) { + for _, age := range []string{"0", "60"} { + auth := &mockAuthProvider{authorizeURL: "https://auth.example/login"} + handler := newAuthorizeHandler(auth, &mockMFAProvider{}, &captureEmitter{}) + params := validAuthorizeParams() + params.Set("prompt", "login") + params.Set("max_age", age) + response := httptest.NewRecorder() + handler.ServeHTTP(response, authorizeRequest(params)) + if response.Code != http.StatusFound { + t.Fatalf("authorize failed: %d", response.Code) + } + expected, _ := time.ParseDuration(age + "s") + if !auth.request.PromptLogin || auth.request.MaxAge == nil || *auth.request.MaxAge != expected { + t.Fatalf("fresh login requirements lost before provider: %+v", auth.request) + } + } +} diff --git a/workplans/KEY-WP-0033-vergabe-fresh-login.md b/workplans/KEY-WP-0033-vergabe-fresh-login.md new file mode 100644 index 0000000..f94cb67 --- /dev/null +++ b/workplans/KEY-WP-0033-vergabe-fresh-login.md @@ -0,0 +1,46 @@ +--- +id: KEY-WP-0033 +type: workplan +title: "Preserve fresh-user authentication for the Vergabe company handoff" +domain: infotech +repo: key-cape +status: active +owner: codex +topic_slug: netkingdom +created: "2026-09-12" +updated: "2026-09-12" +related: [VERGABE-WP-0019, NK-WP-0037, USER-WP-0025] +--- + +## Forward login freshness to the actual authentication provider + +```task +id: KEY-WP-0033-T01 +status: done +priority: high +``` + +KeyCape parsed prompt=login and max_age but dropped them before Authelia. +Forward these through the provider-neutral AuthRequest and the Authelia adapter. +Absent values retain ordinary SSO behavior. Handler and adapter regressions +cover forced login and zero/nonzero maximum age. Full Go suite passes after +correcting the existing example-count regression: the example file already +contains four service clients and the admitted human approver client. + +## Publish, preflight and prove the fresh recipient boundary + +```task +id: KEY-WP-0033-T02 +status: todo +priority: high +``` + +Publish the exact source and validate current live config before replacing the +single issuer instance. The live image is dcebd46/digest 7ff54c54; current main +also contains startup validation and tenant provenance changes documented in +docs/operations.md. Preserve all unrelated client, credential and MFA policy +configuration. NK-WP-0037 adds the exact public Vergabe callback with no tenant +assertion or MFA downgrade. Confirm prompt=login reaches Authelia through the +live redirect, wrong callbacks and missing PKCE fail, and the actual recipient +uses their own identity. Provider restart invalidates pending in-memory logins; +USER-WP-0025-T03 still owns full provider sign-out coordination.