diff --git a/src/internal/adapters/authelia/adapter.go b/src/internal/adapters/authelia/adapter.go index 62388c7..e7af937 100644 --- a/src/internal/adapters/authelia/adapter.go +++ b/src/internal/adapters/authelia/adapter.go @@ -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 diff --git a/src/internal/adapters/authelia/adapter_test.go b/src/internal/adapters/authelia/adapter_test.go index ef9c46d..6526230 100644 --- a/src/internal/adapters/authelia/adapter_test.go +++ b/src/internal/adapters/authelia/adapter_test.go @@ -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) + } + }) } } diff --git a/src/internal/adapters/authelia/issuer_probe.go b/src/internal/adapters/authelia/issuer_probe.go index 32a5b75..082708a 100644 --- a/src/internal/adapters/authelia/issuer_probe.go +++ b/src/internal/adapters/authelia/issuer_probe.go @@ -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" diff --git a/src/internal/adapters/authelia/issuer_probe_test.go b/src/internal/adapters/authelia/issuer_probe_test.go index d583942..7943c3f 100644 --- a/src/internal/adapters/authelia/issuer_probe_test.go +++ b/src/internal/adapters/authelia/issuer_probe_test.go @@ -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")