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 <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:
parent
da2543f47f
commit
8be8065347
4 changed files with 131 additions and 12 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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@<pending>` still needs a rollout to `sso-mfa/k8s/keycape/deployment.yaml`
|
||||
before platform-root can sign in again.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue