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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue