Send split-horizon origin headers on the discovery/JWKS fetch too
All checks were successful
Authentication acceptance / acceptance (push) Successful in 1m23s
Authentication acceptance / provider-contract (push) Successful in 15s
Build and Publish Container Image / build-and-push (push) Successful in 52s

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 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 169987@bnt-lap001
Assistant-Session: 322ef1ef-9048-4021-8570-b6d6f6347999
This commit is contained in:
tegwick 2026-09-27 19:35:51 +02:00
parent da2543f47f
commit 8be8065347
4 changed files with 131 additions and 12 deletions

View file

@ -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.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(a.cfg.ClientID, a.cfg.ClientSecret) req.SetBasicAuth(a.cfg.ClientID, a.cfg.ClientSecret)
a.setPublicOriginHeaders(req) setPublicOriginHeaders(req, a.authorizeBaseURL())
return req, nil return req, nil
} }
// setPublicOriginHeaders annotates a split-horizon token request. A public // setPublicOriginHeaders annotates a split-horizon server-side request to
// origin that is not HTTPS is left unset: sending X-Forwarded-Proto: http is // Authelia with the origin the browser actually uses, so Authelia can derive
// the value Authelia 4.39 refuses. Same-origin calls are left unset too. // the public issuer instead of the in-cluster one. Every server-side call
func (a *AutheliaAdapter) setPublicOriginHeaders(req *http.Request) { // this package makes to Authelia (token exchange, provider-metadata/JWKS
public, err := url.Parse(a.authorizeBaseURL()) // 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 == "" { if err != nil || public.Scheme != "https" || public.Host == "" {
return return
} }
token, err := url.Parse(req.URL.String()) target, err := url.Parse(req.URL.String())
if err != nil || token.Host == "" { if err != nil || target.Host == "" {
return return
} }
if token.Scheme == public.Scheme && strings.EqualFold(token.Host, public.Host) { if target.Scheme == public.Scheme && strings.EqualFold(target.Host, public.Host) {
return return
} }
req.Header.Set("X-Forwarded-Proto", public.Scheme) req.Header.Set("X-Forwarded-Proto", public.Scheme)

View file

@ -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) { func TestHandleCallback_Success_FallsBackToSub(t *testing.T) {
tokenBody := buildTokenResponse(map[string]interface{}{ tokenBody := buildTokenResponse(map[string]interface{}{
"sub": "user-uuid-456", "sub": "user-uuid-456",

View file

@ -25,9 +25,10 @@ import (
// precisely the assumption it was supposed to remove. Verification holds whether // precisely the assumption it was supposed to remove. Verification holds whether
// or not the transport is what we believe it is. // or not the transport is what we believe it is.
type idTokenVerifier struct { type idTokenVerifier struct {
client HTTPClient client HTTPClient
tokenBaseURL string tokenBaseURL string
clientID string publicBaseURL string
clientID string
// issuerOverride and jwksOverride pin metadata for deployments where it // issuerOverride and jwksOverride pin metadata for deployments where it
// cannot be inferred from the token base URL. Empty means "discover". // 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 const leeway = 30 * time.Second
func newIDTokenVerifier(cfg Config, client HTTPClient, tokenBaseURL string) *idTokenVerifier { func newIDTokenVerifier(cfg Config, client HTTPClient, tokenBaseURL string) *idTokenVerifier {
publicBaseURL := cfg.BrowserBaseURL
if publicBaseURL == "" {
publicBaseURL = cfg.BaseURL
}
return &idTokenVerifier{ return &idTokenVerifier{
client: client, client: client,
tokenBaseURL: strings.TrimRight(tokenBaseURL, "/"), tokenBaseURL: strings.TrimRight(tokenBaseURL, "/"),
publicBaseURL: publicBaseURL,
clientID: cfg.ClientID, clientID: cfg.ClientID,
issuerOverride: cfg.Issuer, issuerOverride: cfg.Issuer,
jwksOverride: cfg.JWKSURL, jwksOverride: cfg.JWKSURL,
@ -237,6 +243,7 @@ func (v *idTokenVerifier) get(ctx context.Context, target string) ([]byte, error
return nil, err return nil, err
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
setPublicOriginHeaders(req, v.publicBaseURL)
resp, err := v.client.Do(req) resp, err := v.client.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err

View file

@ -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 timestamp) or informed-decision (correctly refuses to interpret the PDP's
denial). `INFD-IN-0005` can close once informed-decision confirms platform-root denial). `INFD-IN-0005` can close once informed-decision confirms platform-root
can approve after a fresh sign-in against this build. 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@<pending>` still needs a rollout to `sso-mfa/k8s/keycape/deployment.yaml`
before platform-root can sign in again.