Send the public origin on the in-cluster Authelia token request.
All checks were successful
Authentication acceptance / acceptance (push) Successful in 1m10s
Authentication acceptance / provider-contract (push) Successful in 15s
Build and Publish Container Image / build-and-push (push) Successful in 2m1s

The token POST stays on tokenBaseURL. When that origin is not the browser-facing HTTPS origin, the request sets X-Forwarded-Proto and X-Forwarded-Host from that origin. Browser redirects are unchanged.

Assistant: grok
Assistant-Session: 01a0e29c-2e57-7342-a500-8f3b43b5fefd
This commit is contained in:
tegwick 2026-09-27 13:32:06 +02:00
parent df823d3950
commit 3b0446e0ef
4 changed files with 125 additions and 25 deletions

View file

@ -155,21 +155,11 @@ type tokenResponse struct {
// exchangeCode sends a POST to Authelia's token endpoint and returns the
// parsed token response. On any HTTP or status error it returns a non-nil error.
func (a *AutheliaAdapter) exchangeCode(_ context.Context, code string) (*tokenResponse, error) {
tokenURL := strings.TrimRight(a.tokenBaseURL(), "/") + "/api/oidc/token"
body := url.Values{}
body.Set("grant_type", "authorization_code")
body.Set("code", code)
body.Set("redirect_uri", a.cfg.RedirectURI)
body.Set("client_id", a.cfg.ClientID)
req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(body.Encode()))
func (a *AutheliaAdapter) exchangeCode(ctx context.Context, code string) (*tokenResponse, error) {
req, err := a.newTokenRequest(ctx, code)
if err != nil {
return nil, fmt.Errorf("authelia: build token request: %w", err)
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(a.cfg.ClientID, a.cfg.ClientSecret)
resp, err := a.client.Do(req)
if err != nil {
@ -232,6 +222,49 @@ func (a *AutheliaAdapter) tokenBaseURL() string {
return a.cfg.BaseURL
}
// newTokenRequest is the back-channel authorization-code exchange. The URL
// stays on tokenBaseURL. When that origin is not the browser-facing HTTPS
// origin, the request carries that origin in X-Forwarded-Proto and
// X-Forwarded-Host so Authelia can derive the public issuer. Authelia 4.39
// rejects a plain-http scheme. Browser redirects are unchanged.
func (a *AutheliaAdapter) newTokenRequest(ctx context.Context, code string) (*http.Request, error) {
tokenURL := strings.TrimRight(a.tokenBaseURL(), "/") + "/api/oidc/token"
body := url.Values{}
body.Set("grant_type", "authorization_code")
body.Set("code", code)
body.Set("redirect_uri", a.cfg.RedirectURI)
body.Set("client_id", a.cfg.ClientID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(body.Encode()))
if err != nil {
return nil, fmt.Errorf("authelia: build token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(a.cfg.ClientID, a.cfg.ClientSecret)
a.setPublicOriginHeaders(req)
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())
if err != nil || public.Scheme != "https" || public.Host == "" {
return
}
token, err := url.Parse(req.URL.String())
if err != nil || token.Host == "" {
return
}
if token.Scheme == public.Scheme && strings.EqualFold(token.Host, public.Host) {
return
}
req.Header.Set("X-Forwarded-Proto", public.Scheme)
req.Header.Set("X-Forwarded-Host", public.Host)
}
// parseIDTokenClaims extracts the JWT payload claims without verifying
// anything. It is NOT part of the authentication path: HandleCallback verifies
// through idTokenVerifier. Kept for tests and diagnostics that need to read a

View file

@ -295,24 +295,91 @@ func TestHandleCallback_UsesTokenBaseURLWhenConfigured(t *testing.T) {
"sub": "user-uuid-123",
"preferred_username": "alice",
})
var tokenURL string
var tokenReq *http.Request
client := &mockHTTPClient{
doFn: func(req *http.Request) (*http.Response, error) {
tokenURL = req.URL.String()
if req.URL.Path == "/api/oidc/token" {
tokenReq = req
}
return jsonResponse(tokenBody), nil
},
}
cfg := testConfig()
cfg.BaseURL = "https://auth.coulomb.social"
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 !strings.HasPrefix(tokenURL, "http://authelia.sso.svc.cluster.local:9091") {
t.Errorf("expected token exchange to use TokenBaseURL, got: %s", tokenURL)
if tokenReq == nil {
t.Fatal("token endpoint was not called")
}
if tokenReq.URL.String() != "http://authelia.sso.svc.cluster.local:9091/api/oidc/token" {
t.Errorf("token exchange URL: got %s", tokenReq.URL.String())
}
if tokenReq.Host != "authelia.sso.svc.cluster.local:9091" {
t.Errorf("token request host: got %s", tokenReq.Host)
}
if tokenReq.Header.Get("X-Forwarded-Proto") != "https" {
t.Errorf("X-Forwarded-Proto: got %q", tokenReq.Header.Get("X-Forwarded-Proto"))
}
if tokenReq.Header.Get("X-Forwarded-Host") != "auth.coulomb.social" {
t.Errorf("X-Forwarded-Host: got %q", tokenReq.Header.Get("X-Forwarded-Host"))
}
redirect, err := adapter.AuthorizeURL(context.Background(), domain.AuthRequest{
State: "s",
})
if err != nil {
t.Fatalf("AuthorizeURL: %v", err)
}
if !strings.HasPrefix(redirect, "https://auth.coulomb.social/api/oidc/authorization?") {
t.Errorf("browser redirect: got %s", redirect)
}
if strings.Contains(redirect, "authelia.sso.svc.cluster.local") {
t.Errorf("browser redirect must stay on the public origin, got %s", redirect)
}
}
func TestTokenRequestOmitsForwardedHeadersForSameOriginAndPlainHTTP(t *testing.T) {
cases := []authelia.Config{
testConfig(),
{
BaseURL: "http://authelia:9091",
BrowserBaseURL: "http://localhost:9091",
TokenBaseURL: "http://authelia:9091",
ClientID: "keycape",
ClientSecret: "test-secret",
RedirectURI: "http://localhost:8080/authorize/callback",
},
}
for _, cfg := range cases {
t.Run(cfg.BrowserBaseURL+" "+cfg.BaseURL, func(t *testing.T) {
var tokenReq *http.Request
client := &mockHTTPClient{
doFn: func(req *http.Request) (*http.Response, error) {
if req.URL.Path == "/api/oidc/token" {
tokenReq = req
}
return jsonResponse(buildTokenResponse(map[string]interface{}{
"sub": "user-uuid-123",
"preferred_username": "alice",
})), nil
},
}
adapter := authelia.New(cfg, client)
if _, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{Code: "code"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tokenReq == nil {
t.Fatal("token endpoint was not called")
}
if proto, host := tokenReq.Header.Get("X-Forwarded-Proto"), tokenReq.Header.Get("X-Forwarded-Host"); proto != "" || host != "" {
t.Fatalf("forwarded headers set on %s: proto %q host %q", tokenReq.URL.String(), proto, host)
}
})
}
}

View file

@ -166,16 +166,13 @@ func (p *IssuerProbe) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
func (p *IssuerProbe) verifyCode(ctx context.Context, code string) (string, string) {
// Exchange under the same tokenBaseURL/client/redirect as the production
// adapter. Use a bounded reader and cancellation; never relay error bodies.
tokenURL := strings.TrimRight(p.adapter.tokenBaseURL(), "/") + "/api/oidc/token"
form := url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {p.adapter.cfg.RedirectURI}, "client_id": {p.adapter.cfg.ClientID}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
// Exchange under the same token request as the production adapter, including
// the public-origin headers. Use a bounded reader and cancellation; never
// relay error bodies.
req, err := p.adapter.newTokenRequest(ctx, code)
if err != nil {
return "", "token_exchange_error"
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(p.adapter.cfg.ClientID, p.adapter.cfg.ClientSecret)
response, err := p.adapter.client.Do(req)
if err != nil {
return "", "token_exchange_error"

View file

@ -34,6 +34,9 @@ type probeProvider struct {
func (p *probeProvider) Do(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/api/oidc/token" {
p.calls++
if r.Header.Get("X-Forwarded-Proto") != "https" || r.Header.Get("X-Forwarded-Host") != "auth.example.com" {
p.t.Fatalf("token forwarded headers: proto %q host %q", r.Header.Get("X-Forwarded-Proto"), r.Header.Get("X-Forwarded-Host"))
}
user, pass, ok := r.BasicAuth()
if !ok || user != testConfig().ClientID || pass != testConfig().ClientSecret {
p.t.Fatal("wrong client authentication")