From 8be80653473c9166ead528c647c8d3d5a17cec44 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 27 Sep 2026 19:35:51 +0200 Subject: [PATCH] Send split-horizon origin headers on the discovery/JWKS fetch too setPublicOriginHeaders (key-cape@3b0446e) annotated only the token exchange with X-Forwarded-Proto/Host so Authelia can derive the public issuer in a split-horizon deployment. idTokenVerifier's separate provider-metadata/JWKS fetch never got the same treatment, so an in-cluster discovery request reached Authelia unmarked and Authelia 4.38 failed closed on it ("invalid X-Forwarded-Proto header value 'http'"), breaking every login regardless of client. Extracted setPublicOriginHeaders to a package-level function and gave idTokenVerifier the same resolved public base URL so its discovery/JWKS requests carry the same headers as the token exchange. Co-Authored-By: Claude Sonnet 5 Assistant: claude-code Assistant-Model: sonnet Assistant-Process: 169987@bnt-lap001 Assistant-Session: 322ef1ef-9048-4021-8570-b6d6f6347999 --- src/internal/adapters/authelia/adapter.go | 25 ++++--- .../adapters/authelia/adapter_test.go | 70 +++++++++++++++++++ src/internal/adapters/authelia/verify.go | 13 +++- workplans/ADHOC-2026-09-27.md | 35 ++++++++++ 4 files changed, 131 insertions(+), 12 deletions(-) diff --git a/src/internal/adapters/authelia/adapter.go b/src/internal/adapters/authelia/adapter.go index e7af937..b78811a 100644 --- a/src/internal/adapters/authelia/adapter.go +++ b/src/internal/adapters/authelia/adapter.go @@ -242,23 +242,30 @@ func (a *AutheliaAdapter) newTokenRequest(ctx context.Context, code string) (*ht } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.SetBasicAuth(a.cfg.ClientID, a.cfg.ClientSecret) - a.setPublicOriginHeaders(req) + setPublicOriginHeaders(req, a.authorizeBaseURL()) return req, nil } -// setPublicOriginHeaders annotates a split-horizon token request. A public -// origin that is not HTTPS is left unset: sending X-Forwarded-Proto: http is -// the value Authelia 4.39 refuses. Same-origin calls are left unset too. -func (a *AutheliaAdapter) setPublicOriginHeaders(req *http.Request) { - public, err := url.Parse(a.authorizeBaseURL()) +// setPublicOriginHeaders annotates a split-horizon server-side request to +// Authelia with the origin the browser actually uses, so Authelia can derive +// the public issuer instead of the in-cluster one. Every server-side call +// this package makes to Authelia (token exchange, provider-metadata/JWKS +// discovery) needs this, not only the token exchange — an unmarked in-cluster +// request lets Authelia compute its own X-Forwarded-Proto from the literal +// connection scheme, and Authelia 4.38/4.39 fail closed on that (KEY-WP-0033). +// A public origin that is not HTTPS is left unset: sending +// X-Forwarded-Proto: http is the value Authelia 4.39 refuses. Same-origin +// calls are left unset too. +func setPublicOriginHeaders(req *http.Request, publicBaseURL string) { + public, err := url.Parse(publicBaseURL) if err != nil || public.Scheme != "https" || public.Host == "" { return } - token, err := url.Parse(req.URL.String()) - if err != nil || token.Host == "" { + target, err := url.Parse(req.URL.String()) + if err != nil || target.Host == "" { return } - if token.Scheme == public.Scheme && strings.EqualFold(token.Host, public.Host) { + if target.Scheme == public.Scheme && strings.EqualFold(target.Host, public.Host) { return } req.Header.Set("X-Forwarded-Proto", public.Scheme) diff --git a/src/internal/adapters/authelia/adapter_test.go b/src/internal/adapters/authelia/adapter_test.go index 6526230..81ac620 100644 --- a/src/internal/adapters/authelia/adapter_test.go +++ b/src/internal/adapters/authelia/adapter_test.go @@ -383,6 +383,76 @@ func TestTokenRequestOmitsForwardedHeadersForSameOriginAndPlainHTTP(t *testing.T } } +// Regression: the provider-metadata/JWKS discovery fetch used to verify the +// upstream ID token is a separate server-side call from the token exchange, +// and only the token exchange carried the split-horizon X-Forwarded-Proto/ +// Host headers. An in-cluster discovery request with no override header lets +// Authelia compute its own (plain-http) proto and fail closed, breaking every +// login even though the token exchange itself was correctly annotated. +func TestDiscoveryFetch_CarriesPublicOriginHeaders(t *testing.T) { + var discoveryReq, jwksReq *http.Request + client := &discoveryCapturingClient{ + onDiscovery: func(req *http.Request) { discoveryReq = req }, + onJWKS: func(req *http.Request) { jwksReq = req }, + tokenBody: buildTokenResponse(map[string]interface{}{ + "sub": "user-uuid-123", + "preferred_username": "alice", + }), + } + + cfg := testConfig() + cfg.BaseURL = "http://authelia.sso.svc.cluster.local:9091" + cfg.BrowserBaseURL = "https://auth.coulomb.social" + cfg.TokenBaseURL = "http://authelia.sso.svc.cluster.local:9091" + + adapter := authelia.New(cfg, client) + if _, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{Code: "code"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if discoveryReq == nil { + t.Fatal("discovery endpoint was not called") + } + if got := discoveryReq.Header.Get("X-Forwarded-Proto"); got != "https" { + t.Errorf("discovery X-Forwarded-Proto: got %q, want https", got) + } + if got := discoveryReq.Header.Get("X-Forwarded-Host"); got != "auth.coulomb.social" { + t.Errorf("discovery X-Forwarded-Host: got %q, want auth.coulomb.social", got) + } + if jwksReq == nil { + t.Fatal("jwks endpoint was not called") + } + if got := jwksReq.Header.Get("X-Forwarded-Proto"); got != "https" { + t.Errorf("jwks X-Forwarded-Proto: got %q, want https", got) + } + if got := jwksReq.Header.Get("X-Forwarded-Host"); got != "auth.coulomb.social" { + t.Errorf("jwks X-Forwarded-Host: got %q, want auth.coulomb.social", got) + } +} + +// discoveryCapturingClient serves discovery/jwks/token itself (unlike +// mockHTTPClient, which serves discovery/jwks with a fixed response before a +// test ever sees the request) so a test can inspect the headers on those +// specific requests. +type discoveryCapturingClient struct { + onDiscovery func(*http.Request) + onJWKS func(*http.Request) + tokenBody string +} + +func (c *discoveryCapturingClient) Do(req *http.Request) (*http.Response, error) { + switch { + case strings.HasSuffix(req.URL.Path, "/.well-known/openid-configuration"): + c.onDiscovery(req) + return jsonResponse(fmt.Sprintf(`{"issuer":%q,"jwks_uri":%q}`, + testIssuer, testIssuer+"/jwks.json")), nil + case strings.HasSuffix(req.URL.Path, "/jwks.json"): + c.onJWKS(req) + return jsonResponse(testJWKS(nil)), nil + default: + return jsonResponse(c.tokenBody), nil + } +} + func TestHandleCallback_Success_FallsBackToSub(t *testing.T) { tokenBody := buildTokenResponse(map[string]interface{}{ "sub": "user-uuid-456", diff --git a/src/internal/adapters/authelia/verify.go b/src/internal/adapters/authelia/verify.go index 93022a2..7c85d3b 100644 --- a/src/internal/adapters/authelia/verify.go +++ b/src/internal/adapters/authelia/verify.go @@ -25,9 +25,10 @@ import ( // precisely the assumption it was supposed to remove. Verification holds whether // or not the transport is what we believe it is. type idTokenVerifier struct { - client HTTPClient - tokenBaseURL string - clientID string + client HTTPClient + tokenBaseURL string + publicBaseURL string + clientID string // issuerOverride and jwksOverride pin metadata for deployments where it // cannot be inferred from the token base URL. Empty means "discover". @@ -95,9 +96,14 @@ func FailureReason(err error) string { const leeway = 30 * time.Second func newIDTokenVerifier(cfg Config, client HTTPClient, tokenBaseURL string) *idTokenVerifier { + publicBaseURL := cfg.BrowserBaseURL + if publicBaseURL == "" { + publicBaseURL = cfg.BaseURL + } return &idTokenVerifier{ client: client, tokenBaseURL: strings.TrimRight(tokenBaseURL, "/"), + publicBaseURL: publicBaseURL, clientID: cfg.ClientID, issuerOverride: cfg.Issuer, jwksOverride: cfg.JWKSURL, @@ -237,6 +243,7 @@ func (v *idTokenVerifier) get(ctx context.Context, target string) ([]byte, error return nil, err } req.Header.Set("Accept", "application/json") + setPublicOriginHeaders(req, v.publicBaseURL) resp, err := v.client.Do(req) if err != nil { return nil, err diff --git a/workplans/ADHOC-2026-09-27.md b/workplans/ADHOC-2026-09-27.md index 3a6b2b8..0b75554 100644 --- a/workplans/ADHOC-2026-09-27.md +++ b/workplans/ADHOC-2026-09-27.md @@ -63,3 +63,38 @@ Does not touch flex-auth (its 900s window is correct; it was consuming a bad timestamp) or informed-decision (correctly refuses to interpret the PDP's denial). `INFD-IN-0005` can close once informed-decision confirms platform-root can approve after a fresh sign-in against this build. + +## Discovery/JWKS fetch was missing the split-horizon origin headers + +```task +id: ADHOC-2026-09-27-T02 +status: done +priority: high +``` + +Rolling out T01 (image digest `sha256:248e449a...`) broke every KeyCape login, +including unrelated clients (`user-engine-portal`), with `auth_failure` / +`provider_metadata_unavailable` at `/api/oidc/token` on the KeyCape side and +Authelia logging `invalid X-Forwarded-Proto header value 'http'` on +`GET /.well-known/openid-configuration` from KeyCape's pod IP. + +Root cause, unrelated to T01's change: `AutheliaAdapter.setPublicOriginHeaders` +(the NK-WP-0041-T02/path-B fix, key-cape@3b0446e) annotates only the token +exchange (`newTokenRequest`) with `X-Forwarded-Proto`/`X-Forwarded-Host` so +Authelia can derive the public issuer for a split-horizon (in-cluster HTTP, +public HTTPS) deployment. `idTokenVerifier.get` in `verify.go` — used for the +provider-metadata and JWKS fetches that verify every upstream ID token — never +got the same treatment; it built a bare request with only an `Accept` header. +Authelia 4.38 fails closed on the resulting unmarked in-cluster request. This +gap predates today's session (present since 3b0446e); it surfaced now because +this was the first login attempted since that image went live. + +Fix: extracted `setPublicOriginHeaders` from an `AutheliaAdapter` method to a +package-level function taking the target public base URL explicitly, and gave +`idTokenVerifier` its own resolved `publicBaseURL` (same +`BrowserBaseURL`-else-`BaseURL` fallback as the adapter) so `get()` applies +the same headers. Added `TestDiscoveryFetch_CarriesPublicOriginHeaders` +(confirmed it fails without the fix, passes with it). Full suite green. + +New image `key-cape@` still needs a rollout to `sso-mfa/k8s/keycape/deployment.yaml` +before platform-root can sign in again.