Send the public origin on the in-cluster Authelia token request.
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:
parent
df823d3950
commit
3b0446e0ef
4 changed files with 125 additions and 25 deletions
|
|
@ -155,21 +155,11 @@ type tokenResponse struct {
|
||||||
|
|
||||||
// exchangeCode sends a POST to Authelia's token endpoint and returns the
|
// 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.
|
// 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) {
|
func (a *AutheliaAdapter) exchangeCode(ctx context.Context, code string) (*tokenResponse, error) {
|
||||||
tokenURL := strings.TrimRight(a.tokenBaseURL(), "/") + "/api/oidc/token"
|
req, err := a.newTokenRequest(ctx, code)
|
||||||
|
|
||||||
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()))
|
|
||||||
if err != nil {
|
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)
|
resp, err := a.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -232,6 +222,49 @@ func (a *AutheliaAdapter) tokenBaseURL() string {
|
||||||
return a.cfg.BaseURL
|
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
|
// parseIDTokenClaims extracts the JWT payload claims without verifying
|
||||||
// anything. It is NOT part of the authentication path: HandleCallback verifies
|
// anything. It is NOT part of the authentication path: HandleCallback verifies
|
||||||
// through idTokenVerifier. Kept for tests and diagnostics that need to read a
|
// through idTokenVerifier. Kept for tests and diagnostics that need to read a
|
||||||
|
|
|
||||||
|
|
@ -295,24 +295,91 @@ func TestHandleCallback_UsesTokenBaseURLWhenConfigured(t *testing.T) {
|
||||||
"sub": "user-uuid-123",
|
"sub": "user-uuid-123",
|
||||||
"preferred_username": "alice",
|
"preferred_username": "alice",
|
||||||
})
|
})
|
||||||
var tokenURL string
|
var tokenReq *http.Request
|
||||||
client := &mockHTTPClient{
|
client := &mockHTTPClient{
|
||||||
doFn: func(req *http.Request) (*http.Response, error) {
|
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
|
return jsonResponse(tokenBody), nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := testConfig()
|
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"
|
cfg.TokenBaseURL = "http://authelia.sso.svc.cluster.local:9091"
|
||||||
|
|
||||||
adapter := authelia.New(cfg, client)
|
adapter := authelia.New(cfg, client)
|
||||||
if _, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{Code: "code"}); err != nil {
|
if _, err := adapter.HandleCallback(context.Background(), domain.CallbackParams{Code: "code"}); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(tokenURL, "http://authelia.sso.svc.cluster.local:9091") {
|
if tokenReq == nil {
|
||||||
t.Errorf("expected token exchange to use TokenBaseURL, got: %s", tokenURL)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func (p *IssuerProbe) verifyCode(ctx context.Context, code string) (string, string) {
|
||||||
// Exchange under the same tokenBaseURL/client/redirect as the production
|
// Exchange under the same token request as the production adapter, including
|
||||||
// adapter. Use a bounded reader and cancellation; never relay error bodies.
|
// the public-origin headers. Use a bounded reader and cancellation; never
|
||||||
tokenURL := strings.TrimRight(p.adapter.tokenBaseURL(), "/") + "/api/oidc/token"
|
// relay error bodies.
|
||||||
form := url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {p.adapter.cfg.RedirectURI}, "client_id": {p.adapter.cfg.ClientID}}
|
req, err := p.adapter.newTokenRequest(ctx, code)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "token_exchange_error"
|
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)
|
response, err := p.adapter.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "token_exchange_error"
|
return "", "token_exchange_error"
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,9 @@ type probeProvider struct {
|
||||||
func (p *probeProvider) Do(r *http.Request) (*http.Response, error) {
|
func (p *probeProvider) Do(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/api/oidc/token" {
|
if r.URL.Path == "/api/oidc/token" {
|
||||||
p.calls++
|
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()
|
user, pass, ok := r.BasicAuth()
|
||||||
if !ok || user != testConfig().ClientID || pass != testConfig().ClientSecret {
|
if !ok || user != testConfig().ClientID || pass != testConfig().ClientSecret {
|
||||||
p.t.Fatal("wrong client authentication")
|
p.t.Fatal("wrong client authentication")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue