From eeddded50fcb00267bb7fdabb6c4799d02848fa2 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Sun, 28 Jun 2026 13:18:54 +0300 Subject: [PATCH] Support split OIDC provider URLs --- cmd/explorer/main.go | 10 + deploy/helm/radar/templates/deployment.yaml | 15 + deploy/helm/radar/values.schema.json | 5 + deploy/helm/radar/values.yaml | 10 + docs/authentication.md | 30 ++ internal/auth/oidc.go | 205 +++++++- internal/auth/oidc_test.go | 161 +++++- pkg/auth/types.go | 23 +- scripts/test-oidc-split.sh | 512 ++++++++++++++++++++ 9 files changed, 942 insertions(+), 29 deletions(-) create mode 100755 scripts/test-oidc-split.sh diff --git a/cmd/explorer/main.go b/cmd/explorer/main.go index a1f73a4a3..f82d822f1 100644 --- a/cmd/explorer/main.go +++ b/cmd/explorer/main.go @@ -89,6 +89,11 @@ func main() { authGroupsHeader := flag.String("auth-groups-header", "X-Forwarded-Groups", "Header for groups (proxy mode)") authProxyLogoutURL := flag.String("auth-proxy-logout-url", "", "URL the logout button redirects to in proxy mode, to tear down the upstream proxy session (e.g. oauth2-proxy's /oauth2/sign_out). The proxy must actually invalidate the session at this URL — Radar only clears its own cookie (Basic Auth has no logout). Empty = clear Radar's cookie only.") authOIDCIssuer := flag.String("auth-oidc-issuer", "", "OIDC issuer URL") + authOIDCInternalIssuer := flag.String("auth-oidc-internal-issuer", "", "Internal OIDC issuer base URL used by Radar for discovery and derived server-side endpoints; tokens are still validated against --auth-oidc-issuer") + authOIDCAuthorizationURL := flag.String("auth-oidc-authorization-url", "", "Browser-facing OIDC authorization endpoint URL (overrides discovery)") + authOIDCTokenURL := flag.String("auth-oidc-token-url", "", "Server-side OIDC token endpoint URL (overrides discovery)") + authOIDCUserInfoURL := flag.String("auth-oidc-userinfo-url", "", "Server-side OIDC userinfo endpoint URL (overrides discovery)") + authOIDCJWKSURL := flag.String("auth-oidc-jwks-url", "", "Server-side OIDC JWKS endpoint URL (overrides discovery)") authOIDCClientID := flag.String("auth-oidc-client-id", "", "OIDC client ID") authOIDCClientSecret := flag.String("auth-oidc-client-secret", "", "OIDC client secret") authOIDCRedirectURL := flag.String("auth-oidc-redirect-url", "", "OIDC redirect URL") @@ -229,6 +234,11 @@ func main() { GroupsHeader: *authGroupsHeader, ProxyLogoutURL: *authProxyLogoutURL, OIDCIssuer: *authOIDCIssuer, + OIDCInternalIssuer: *authOIDCInternalIssuer, + OIDCAuthorizationURL: *authOIDCAuthorizationURL, + OIDCTokenURL: *authOIDCTokenURL, + OIDCUserInfoURL: *authOIDCUserInfoURL, + OIDCJWKSURL: *authOIDCJWKSURL, OIDCClientID: *authOIDCClientID, OIDCClientSecret: *authOIDCClientSecret, OIDCRedirectURL: *authOIDCRedirectURL, diff --git a/deploy/helm/radar/templates/deployment.yaml b/deploy/helm/radar/templates/deployment.yaml index f048157d9..905c3b798 100644 --- a/deploy/helm/radar/templates/deployment.yaml +++ b/deploy/helm/radar/templates/deployment.yaml @@ -92,6 +92,21 @@ spec: {{- if .Values.auth.oidc.issuerURL }} - {{ printf "--auth-oidc-issuer=%s" .Values.auth.oidc.issuerURL | quote }} {{- end }} + {{- if .Values.auth.oidc.internalIssuerURL }} + - {{ printf "--auth-oidc-internal-issuer=%s" .Values.auth.oidc.internalIssuerURL | quote }} + {{- end }} + {{- if .Values.auth.oidc.authorizationURL }} + - {{ printf "--auth-oidc-authorization-url=%s" .Values.auth.oidc.authorizationURL | quote }} + {{- end }} + {{- if .Values.auth.oidc.tokenURL }} + - {{ printf "--auth-oidc-token-url=%s" .Values.auth.oidc.tokenURL | quote }} + {{- end }} + {{- if .Values.auth.oidc.userInfoURL }} + - {{ printf "--auth-oidc-userinfo-url=%s" .Values.auth.oidc.userInfoURL | quote }} + {{- end }} + {{- if .Values.auth.oidc.jwksURL }} + - {{ printf "--auth-oidc-jwks-url=%s" .Values.auth.oidc.jwksURL | quote }} + {{- end }} {{- if .Values.auth.oidc.clientID }} - {{ printf "--auth-oidc-client-id=%s" .Values.auth.oidc.clientID | quote }} {{- end }} diff --git a/deploy/helm/radar/values.schema.json b/deploy/helm/radar/values.schema.json index ae857ae2f..abae35ff5 100644 --- a/deploy/helm/radar/values.schema.json +++ b/deploy/helm/radar/values.schema.json @@ -198,6 +198,11 @@ "additionalProperties": true, "properties": { "issuerURL": { "type": "string" }, + "internalIssuerURL": { "type": "string" }, + "authorizationURL": { "type": "string" }, + "tokenURL": { "type": "string" }, + "userInfoURL": { "type": "string" }, + "jwksURL": { "type": "string" }, "clientID": { "type": "string" }, "clientSecret": { "type": "string" }, "existingSecret": { "type": "string" }, diff --git a/deploy/helm/radar/values.yaml b/deploy/helm/radar/values.yaml index aa9fbc63e..866de76ca 100644 --- a/deploy/helm/radar/values.yaml +++ b/deploy/helm/radar/values.yaml @@ -279,6 +279,16 @@ auth: # OIDC mode settings oidc: issuerURL: "" + # Optional internal issuer base URL used by the Radar pod for discovery and + # derived server-side token/userinfo/JWKS endpoints. Tokens are still + # validated against issuerURL, and browser redirects stay browser-facing. + internalIssuerURL: "" + # Optional endpoint overrides. Use these when browser-facing and server-side + # OIDC URLs cannot be derived from issuerURL/internalIssuerURL. + authorizationURL: "" + tokenURL: "" + userInfoURL: "" + jwksURL: "" clientID: "" clientSecret: "" # Use an existing K8s Secret for the OIDC client secret (avoids storing in Helm values). diff --git a/docs/authentication.md b/docs/authentication.md index e7649b31d..24b86fc57 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -90,6 +90,31 @@ auth: **Scopes:** by default Radar requests `openid profile email groups` at the authorization endpoint. The `groups` scope is required by Dex, Keycloak, and most IdPs to actually include the groups claim in the ID token. If your IdP rejects unknown scopes (Google in particular doesn't define `groups`), override via `auth.oidc.scopes` / `--auth-oidc-scopes` to drop it or substitute the provider-specific equivalent. +**Split public/internal provider URLs:** Kubernetes deployments sometimes need the browser to use the canonical issuer URL while the Radar pod talks to the IdP through service DNS. Keep `issuerURL` set to the canonical browser-facing issuer; Radar still validates the token `iss` claim against that value. Set `internalIssuerURL` when the internal endpoint has the same path layout, and Radar will fetch discovery internally while deriving server-side token, userinfo, and JWKS URLs from the internal base: + +```yaml +auth: + mode: oidc + oidc: + issuerURL: http://authentik.example.com/application/o/radar/ + internalIssuerURL: http://authentik-server.authentik.svc.cluster.local/application/o/radar/ + clientID: radar + redirectURL: https://radar.example.com/auth/callback +``` + +If the endpoints cannot be derived from the two issuer bases, override them explicitly. `authorizationURL` is browser-facing; `tokenURL`, `userInfoURL`, and `jwksURL` are server-side URLs used by the Radar process: + +```yaml +auth: + mode: oidc + oidc: + issuerURL: http://authentik.example.com/application/o/radar/ + authorizationURL: http://authentik.example.com/application/o/radar/authorize/ + tokenURL: http://authentik-server.authentik.svc.cluster.local/application/o/radar/token/ + userInfoURL: http://authentik-server.authentik.svc.cluster.local/application/o/radar/userinfo/ + jwksURL: http://authentik-server.authentik.svc.cluster.local/application/o/radar/jwks/ +``` + **Logout behavior:** When a user clicks logout, Radar clears the local session cookie and — if the identity provider supports it — redirects the browser to the provider's logout endpoint ([RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html)) to terminate the SSO session as well. This prevents the common issue where the user appears to log out but is silently re-authenticated on the next visit. @@ -393,6 +418,11 @@ Radar uses stateless HMAC-SHA256 signed cookies for sessions. The cookie contain | Groups header (proxy) | `--auth-groups-header` | `auth.proxy.groupsHeader` | `X-Forwarded-Groups` | | Proxy logout URL (proxy) | `--auth-proxy-logout-url` | `auth.proxy.logoutURL` | — | | OIDC issuer | `--auth-oidc-issuer` | `auth.oidc.issuerURL` | — | +| OIDC internal issuer | `--auth-oidc-internal-issuer` | `auth.oidc.internalIssuerURL` | — | +| OIDC authorization URL | `--auth-oidc-authorization-url` | `auth.oidc.authorizationURL` | — | +| OIDC token URL | `--auth-oidc-token-url` | `auth.oidc.tokenURL` | — | +| OIDC userinfo URL | `--auth-oidc-userinfo-url` | `auth.oidc.userInfoURL` | — | +| OIDC JWKS URL | `--auth-oidc-jwks-url` | `auth.oidc.jwksURL` | — | | OIDC client ID | `--auth-oidc-client-id` | `auth.oidc.clientID` | — | | OIDC client secret | `--auth-oidc-client-secret` | `auth.oidc.clientSecret` | — | | OIDC client secret (K8s Secret) | — | `auth.oidc.existingSecret` | — | diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index 21f86095b..55caed5ee 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -8,10 +8,12 @@ import ( "encoding/hex" "encoding/json" "fmt" + "io" "log" "net/http" "net/url" "os" + "strings" "time" "github.com/coreos/go-oidc/v3/oidc" @@ -36,6 +38,30 @@ type OIDCHandler struct { backchannelLogoutSessionSupported bool } +type oidcDiscoveryClaims struct { + EndSessionEndpoint string `json:"end_session_endpoint"` + BackchannelLogoutSupported bool `json:"backchannel_logout_supported"` + BackchannelLogoutSessionSupported bool `json:"backchannel_logout_session_supported"` +} + +type oidcProviderMetadata struct { + oidc.ProviderConfig + oidcDiscoveryClaims +} + +var supportedOIDCSigningAlgorithms = map[string]bool{ + oidc.RS256: true, + oidc.RS384: true, + oidc.RS512: true, + oidc.ES256: true, + oidc.ES384: true, + oidc.ES512: true, + oidc.PS256: true, + oidc.PS384: true, + oidc.PS512: true, + oidc.EdDSA: true, +} + // NewOIDCHandler creates a new OIDC handler. Returns an error if the provider // cannot be discovered (network error, invalid issuer URL, etc.). func NewOIDCHandler(ctx context.Context, cfg Config) (*OIDCHandler, error) { @@ -68,7 +94,7 @@ func NewOIDCHandler(ctx context.Context, cfg Config) (*OIDCHandler, error) { ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) } - provider, err := oidc.NewProvider(ctx, cfg.OIDCIssuer) + provider, providerClaims, err := newOIDCProvider(ctx, cfg) if err != nil { return nil, err } @@ -87,16 +113,7 @@ func NewOIDCHandler(ctx context.Context, cfg Config) (*OIDCHandler, error) { log.Printf("[oidc] Requesting scopes: %v", scopes) verifier := provider.Verifier(&oidc.Config{ClientID: cfg.OIDCClientID}) - - // Extract endpoints and feature flags from OIDC discovery document - var providerClaims struct { - EndSessionEndpoint string `json:"end_session_endpoint"` - BackchannelLogoutSupported bool `json:"backchannel_logout_supported"` - BackchannelLogoutSessionSupported bool `json:"backchannel_logout_session_supported"` - } - if err := provider.Claims(&providerClaims); err != nil { - log.Printf("[oidc] Warning: failed to parse end_session_endpoint from discovery document: %v", err) - } else if providerClaims.EndSessionEndpoint != "" { + if providerClaims.EndSessionEndpoint != "" { log.Printf("[oidc] RP-Initiated Logout enabled (end_session_endpoint discovered)") } else { log.Printf("[oidc] IdP does not advertise end_session_endpoint — will use prompt=login on next auth after logout") @@ -127,6 +144,172 @@ func NewOIDCHandler(ctx context.Context, cfg Config) (*OIDCHandler, error) { return h, nil } +func newOIDCProvider(ctx context.Context, cfg Config) (*oidc.Provider, oidcDiscoveryClaims, error) { + if !hasCustomOIDCEndpoints(cfg) { + provider, err := oidc.NewProvider(ctx, cfg.OIDCIssuer) + if err != nil { + return nil, oidcDiscoveryClaims{}, err + } + var claims oidcDiscoveryClaims + if err := provider.Claims(&claims); err != nil { + log.Printf("[oidc] Warning: failed to parse optional OIDC discovery claims: %v", err) + } + return provider, claims, nil + } + + metadata, err := resolveOIDCProviderMetadata(ctx, cfg) + if err != nil { + return nil, oidcDiscoveryClaims{}, err + } + return metadata.ProviderConfig.NewProvider(ctx), metadata.oidcDiscoveryClaims, nil +} + +func hasCustomOIDCEndpoints(cfg Config) bool { + return cfg.OIDCInternalIssuer != "" || + cfg.OIDCAuthorizationURL != "" || + cfg.OIDCTokenURL != "" || + cfg.OIDCUserInfoURL != "" || + cfg.OIDCJWKSURL != "" +} + +func resolveOIDCProviderMetadata(ctx context.Context, cfg Config) (*oidcProviderMetadata, error) { + var metadata oidcProviderMetadata + if cfg.OIDCInternalIssuer != "" || !hasExplicitRequiredOIDCEndpoints(cfg) { + discoveryIssuer := cfg.OIDCIssuer + if cfg.OIDCInternalIssuer != "" { + discoveryIssuer = cfg.OIDCInternalIssuer + } + discovered, err := fetchOIDCProviderMetadata(ctx, discoveryIssuer) + if err != nil { + return nil, err + } + metadata = *discovered + if metadata.IssuerURL != cfg.OIDCIssuer { + return nil, fmt.Errorf("oidc: issuer URL configured for Radar (%q) did not match the issuer URL returned by provider discovery (%q)", cfg.OIDCIssuer, metadata.IssuerURL) + } + } else { + metadata.ProviderConfig.IssuerURL = cfg.OIDCIssuer + } + + metadata.ProviderConfig.IssuerURL = cfg.OIDCIssuer + metadata.ProviderConfig.Algorithms = filterOIDCSigningAlgorithms(metadata.ProviderConfig.Algorithms) + applyOIDCEndpointConfig(&metadata, cfg) + if err := validateOIDCProviderMetadata(metadata.ProviderConfig); err != nil { + return nil, err + } + return &metadata, nil +} + +func hasExplicitRequiredOIDCEndpoints(cfg Config) bool { + return cfg.OIDCAuthorizationURL != "" && cfg.OIDCTokenURL != "" && cfg.OIDCJWKSURL != "" +} + +func filterOIDCSigningAlgorithms(algs []string) []string { + if len(algs) == 0 { + return nil + } + filtered := make([]string, 0, len(algs)) + for _, alg := range algs { + if supportedOIDCSigningAlgorithms[alg] { + filtered = append(filtered, alg) + } + } + return filtered +} + +func fetchOIDCProviderMetadata(ctx context.Context, issuer string) (*oidcProviderMetadata, error) { + wellKnown := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnown, nil) + if err != nil { + return nil, err + } + + client := http.DefaultClient + if c, ok := ctx.Value(oauth2.HTTPClient).(*http.Client); ok && c != nil { + client = c + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("unable to read response body: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: %s", resp.Status, body) + } + + var metadata oidcProviderMetadata + if err := json.Unmarshal(body, &metadata); err != nil { + return nil, fmt.Errorf("oidc: failed to decode provider discovery object: %w", err) + } + return &metadata, nil +} + +func applyOIDCEndpointConfig(metadata *oidcProviderMetadata, cfg Config) { + publicIssuer := cfg.OIDCIssuer + internalIssuer := cfg.OIDCInternalIssuer + + if cfg.OIDCAuthorizationURL != "" { + metadata.AuthURL = cfg.OIDCAuthorizationURL + } else if internalIssuer != "" { + metadata.AuthURL = replaceOIDCURLBase(metadata.AuthURL, internalIssuer, publicIssuer) + } + + if cfg.OIDCTokenURL != "" { + metadata.TokenURL = cfg.OIDCTokenURL + } else if internalIssuer != "" { + metadata.TokenURL = replaceOIDCURLBase(metadata.TokenURL, publicIssuer, internalIssuer) + } + + if cfg.OIDCUserInfoURL != "" { + metadata.UserInfoURL = cfg.OIDCUserInfoURL + } else if internalIssuer != "" { + metadata.UserInfoURL = replaceOIDCURLBase(metadata.UserInfoURL, publicIssuer, internalIssuer) + } + + if cfg.OIDCJWKSURL != "" { + metadata.JWKSURL = cfg.OIDCJWKSURL + } else if internalIssuer != "" { + metadata.JWKSURL = replaceOIDCURLBase(metadata.JWKSURL, publicIssuer, internalIssuer) + } + + if internalIssuer != "" { + metadata.EndSessionEndpoint = replaceOIDCURLBase(metadata.EndSessionEndpoint, internalIssuer, publicIssuer) + } +} + +func validateOIDCProviderMetadata(metadata oidc.ProviderConfig) error { + if metadata.AuthURL == "" { + return fmt.Errorf("oidc: authorization endpoint is empty") + } + if metadata.TokenURL == "" { + return fmt.Errorf("oidc: token endpoint is empty") + } + if metadata.JWKSURL == "" { + return fmt.Errorf("oidc: JWKS endpoint is empty") + } + return nil +} + +func replaceOIDCURLBase(raw, fromBase, toBase string) string { + if raw == "" || fromBase == "" || toBase == "" { + return raw + } + from := strings.TrimRight(fromBase, "/") + to := strings.TrimRight(toBase, "/") + if raw == from { + return to + } + if strings.HasPrefix(raw, from+"/") { + return to + strings.TrimPrefix(raw, from) + } + return raw +} + // HandleLogin redirects to the OIDC provider for authentication func (h *OIDCHandler) HandleLogin(w http.ResponseWriter, r *http.Request) { // Generate random state nonce and store in a short-lived cookie for CSRF protection diff --git a/internal/auth/oidc_test.go b/internal/auth/oidc_test.go index 7b45bdf5c..fe1bafaa9 100644 --- a/internal/auth/oidc_test.go +++ b/internal/auth/oidc_test.go @@ -2,8 +2,11 @@ package auth import ( "context" + "crypto/rand" + "crypto/rsa" "encoding/json" "encoding/pem" + "fmt" "net/http" "net/http/httptest" "os" @@ -11,6 +14,8 @@ import ( "testing" "time" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/coreos/go-oidc/v3/oidc/oidctest" "golang.org/x/oauth2" ) @@ -289,12 +294,12 @@ func newTLSOIDCServer() *httptest.Server { srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ - "issuer": srv.URL, - "authorization_endpoint": srv.URL + "/auth", - "token_endpoint": srv.URL + "/token", - "jwks_uri": srv.URL + "/jwks", - "response_types_supported": []string{"code"}, - "subject_types_supported": []string{"public"}, + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/auth", + "token_endpoint": srv.URL + "/token", + "jwks_uri": srv.URL + "/jwks", + "response_types_supported": []string{"code"}, + "subject_types_supported": []string{"public"}, "id_token_signing_alg_values_supported": []string{"RS256"}, }) })) @@ -306,9 +311,9 @@ func TestNewOIDCHandler_FailsWithSelfSignedCert(t *testing.T) { defer srv.Close() _, err := NewOIDCHandler(context.Background(), Config{ - Mode: "oidc", - OIDCIssuer: srv.URL, - OIDCClientID: "test", + Mode: "oidc", + OIDCIssuer: srv.URL, + OIDCClientID: "test", OIDCClientSecret: "secret", OIDCRedirectURL: "http://localhost/callback", }) @@ -420,6 +425,144 @@ func TestNewOIDCHandler_CACertTakesPrecedence(t *testing.T) { } } +func TestNewOIDCHandler_InternalIssuerUsesInternalEndpoints(t *testing.T) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + + publicIssuer := "https://auth.example.com" + provider := &oidctest.Server{ + PublicKeys: []oidctest.PublicKey{{ + PublicKey: priv.Public(), + KeyID: "test-key", + Algorithm: oidc.RS256, + }}, + } + provider.SetIssuer(publicIssuer) + srv := httptest.NewServer(provider) + defer srv.Close() + + h, err := NewOIDCHandler(context.Background(), Config{ + Mode: "oidc", + OIDCIssuer: publicIssuer, + OIDCInternalIssuer: srv.URL, + OIDCClientID: "radar", + OIDCClientSecret: "secret", + OIDCRedirectURL: "http://localhost/callback", + }) + if err != nil { + t.Fatalf("expected success with internal issuer, got: %v", err) + } + + if got, want := h.oauth.Endpoint.AuthURL, publicIssuer+"/auth"; got != want { + t.Errorf("AuthURL = %q, want %q", got, want) + } + if got, want := h.oauth.Endpoint.TokenURL, srv.URL+"/token"; got != want { + t.Errorf("TokenURL = %q, want %q", got, want) + } + + claims := fmt.Sprintf(`{ + "iss": %q, + "aud": "radar", + "sub": "alice", + "exp": %d + }`, publicIssuer, time.Now().Add(time.Hour).Unix()) + rawIDToken := oidctest.SignIDToken(priv, "test-key", oidc.RS256, claims) + if _, err := h.verifier.Verify(context.Background(), rawIDToken); err != nil { + t.Fatalf("expected token verification through internal JWKS URL to succeed: %v", err) + } + + wrongIssuerClaims := fmt.Sprintf(`{ + "iss": %q, + "aud": "radar", + "sub": "alice", + "exp": %d + }`, srv.URL, time.Now().Add(time.Hour).Unix()) + wrongIssuerToken := oidctest.SignIDToken(priv, "test-key", oidc.RS256, wrongIssuerClaims) + if _, err := h.verifier.Verify(context.Background(), wrongIssuerToken); err == nil { + t.Fatal("expected token with internal issuer claim to be rejected") + } +} + +func TestNewOIDCHandler_InternalIssuerWithOverridesKeepsDiscoveryMetadata(t *testing.T) { + publicIssuer := "https://auth.example.com/application/o/radar" + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/application/o/radar/.well-known/openid-configuration" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "issuer": publicIssuer, + "authorization_endpoint": srv.URL + "/application/o/radar/authorize", + "token_endpoint": srv.URL + "/application/o/radar/token", + "userinfo_endpoint": srv.URL + "/application/o/radar/userinfo", + "jwks_uri": srv.URL + "/application/o/radar/jwks", + "end_session_endpoint": srv.URL + "/application/o/radar/logout", + "backchannel_logout_supported": true, + "backchannel_logout_session_supported": true, + "id_token_signing_alg_values_supported": []string{"RS256"}, + }) + })) + defer srv.Close() + + h, err := NewOIDCHandler(context.Background(), Config{ + Mode: "oidc", + OIDCIssuer: publicIssuer, + OIDCInternalIssuer: srv.URL + "/application/o/radar", + OIDCAuthorizationURL: publicIssuer + "/authorize", + OIDCTokenURL: srv.URL + "/custom-token", + OIDCJWKSURL: srv.URL + "/custom-jwks", + OIDCClientID: "radar", + OIDCClientSecret: "secret", + OIDCRedirectURL: "http://localhost/callback", + }) + if err != nil { + t.Fatalf("expected success with internal issuer and overrides, got: %v", err) + } + + if got, want := h.oauth.Endpoint.AuthURL, publicIssuer+"/authorize"; got != want { + t.Errorf("AuthURL = %q, want %q", got, want) + } + if got, want := h.oauth.Endpoint.TokenURL, srv.URL+"/custom-token"; got != want { + t.Errorf("TokenURL = %q, want %q", got, want) + } + if got, want := h.endSessionEndpoint, publicIssuer+"/logout"; got != want { + t.Errorf("endSessionEndpoint = %q, want %q", got, want) + } + if !h.backchannelLogoutSupported || !h.backchannelLogoutSessionSupported { + t.Error("backchannel logout support flags should come from discovery") + } +} + +func TestNewOIDCHandler_ExplicitEndpointsDoNotRequireDiscovery(t *testing.T) { + h, err := NewOIDCHandler(context.Background(), Config{ + Mode: "oidc", + OIDCIssuer: "https://auth.example.com", + OIDCAuthorizationURL: "https://auth.example.com/authorize", + OIDCTokenURL: "http://auth.authentik.svc/token", + OIDCUserInfoURL: "http://auth.authentik.svc/userinfo", + OIDCJWKSURL: "http://auth.authentik.svc/jwks", + OIDCClientID: "radar", + OIDCClientSecret: "secret", + OIDCRedirectURL: "http://localhost/callback", + }) + if err != nil { + t.Fatalf("expected explicit endpoints to initialize without discovery: %v", err) + } + if got, want := h.oauth.Endpoint.AuthURL, "https://auth.example.com/authorize"; got != want { + t.Errorf("AuthURL = %q, want %q", got, want) + } + if got, want := h.oauth.Endpoint.TokenURL, "http://auth.authentik.svc/token"; got != want { + t.Errorf("TokenURL = %q, want %q", got, want) + } + if got, want := h.provider.UserInfoEndpoint(), "http://auth.authentik.svc/userinfo"; got != want { + t.Errorf("UserInfoEndpoint = %q, want %q", got, want) + } +} + func TestNewOIDCHandler_InvalidCACertPath(t *testing.T) { _, err := NewOIDCHandler(context.Background(), Config{ Mode: "oidc", diff --git a/pkg/auth/types.go b/pkg/auth/types.go index 8a04abf20..3ab404c64 100644 --- a/pkg/auth/types.go +++ b/pkg/auth/types.go @@ -26,18 +26,23 @@ type Config struct { Revoker SessionRevoker // OIDC mode - OIDCIssuer string - OIDCClientID string - OIDCClientSecret string + OIDCIssuer string + OIDCInternalIssuer string + OIDCAuthorizationURL string + OIDCTokenURL string + OIDCUserInfoURL string + OIDCJWKSURL string + OIDCClientID string + OIDCClientSecret string OIDCRedirectURL string OIDCGroupsClaim string // default "groups" OIDCScopes []string // OAuth2 scopes requested at authorization; default ["openid", "profile", "email", "groups"] - OIDCPostLogoutRedirectURL string // optional, URL to redirect after IdP logout - OIDCUsernamePrefix string // prefix added to OIDC username for K8s impersonation (e.g., "oidc:") - OIDCGroupsPrefix string // prefix added to OIDC groups for K8s impersonation (e.g., "oidc:") - OIDCInsecureSkipVerify bool // skip TLS verification for OIDC provider (dev/test only) - OIDCCACert string // path to CA certificate file for OIDC provider TLS - OIDCBackchannelLogout bool // enable backchannel logout endpoint + OIDCPostLogoutRedirectURL string // optional, URL to redirect after IdP logout + OIDCUsernamePrefix string // prefix added to OIDC username for K8s impersonation (e.g., "oidc:") + OIDCGroupsPrefix string // prefix added to OIDC groups for K8s impersonation (e.g., "oidc:") + OIDCInsecureSkipVerify bool // skip TLS verification for OIDC provider (dev/test only) + OIDCCACert string // path to CA certificate file for OIDC provider TLS + OIDCBackchannelLogout bool // enable backchannel logout endpoint } // SessionRevoker checks whether a session has been revoked (e.g., via OIDC diff --git a/scripts/test-oidc-split.sh b/scripts/test-oidc-split.sh new file mode 100755 index 000000000..4c172b2e1 --- /dev/null +++ b/scripts/test-oidc-split.sh @@ -0,0 +1,512 @@ +#!/usr/bin/env bash +# +# Smoke-test split public/internal OIDC issuer support. +# +# Why this exists: +# Issue #981 is not just a metadata parsing problem. The failure mode shows +# up across the real OIDC browser redirect + callback + token exchange + +# JWKS verification flow when the browser-facing issuer URL is different +# from the URL Radar can reach from inside the cluster. Unit tests cover the +# resolver rules, but they do not prove the full OAuth code flow still works +# against a real provider. +# +# What this tests: +# - Starts Dex locally with a canonical public issuer URL. +# - Exposes that public issuer through a tiny reverse proxy. +# - The proxy forwards browser endpoints but deliberately blocks public +# /token, /keys, and /userinfo requests. +# - Starts Radar with: +# --auth-oidc-issuer= +# --auth-oidc-internal-issuer= +# - Completes a real Dex password login with curl. +# - Asserts that: +# * the browser authorization path used the public issuer, +# * Radar created a valid OIDC session for admin@example.com, +# * Radar never called the public token/JWKS/userinfo endpoints. +# +# Why it is worth keeping in the repo: +# This is a cheap, reproducible manual smoke test for a deployment shape that +# is otherwise awkward to recreate. It does not need an AWS cluster, a kind +# cluster, ingress, DNS, or hostAliases; Docker plus the built Radar binary is +# enough. Keeping it next to the other auth smoke scripts makes it easy to +# rerun before changing OIDC internals and gives future #981-style regressions +# a concrete reproduction. +# +# When to run it: +# - Before merging changes to internal/auth/oidc.go. +# - When changing OIDC CLI flags, Helm values/templates, or auth docs. +# - After upgrading go-oidc, oauth2, Dex test image versions, or auth cookie +# behavior. +# - When debugging a customer/provider setup where public issuer URLs and +# in-cluster IdP URLs differ. +# It is intentionally not part of default CI: it depends on Docker and scrapes +# Dex's login form, so it is best treated as an explicit smoke test. +# +# What this does not test: +# - Kubernetes impersonation/RBAC after login. +# - TLS certificate behavior or real ingress/DNS. +# - Every OIDC provider's quirks. Dex is only the local provider used to +# exercise the standard code flow. +# +# Prerequisites: +# - radar binary built (make build) +# - docker +# - curl, jq, python3 +# +# Usage: +# ./scripts/test-oidc-split.sh [radar-binary-path] +# +# Useful knobs: +# DEX_IMAGE=ghcr.io/dexidp/dex: Override the Dex image. +# KEEP_LOGS=1 Keep temp logs after a successful run. + +set -euo pipefail + +RADAR="${1:-./radar}" +DEX_IMAGE="${DEX_IMAGE:-ghcr.io/dexidp/dex:v2.41.1}" + +PASS=0 +FAIL=0 +RADAR_PID="" +PROXY_PID="" +DEX_CONTAINER="" +TMP_DIR="" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + echo -e "${RED}Missing required command: $cmd${NC}" + exit 1 + fi +} + +random_port() { + python3 - <<'PY' +import random +import socket + +for _ in range(100): + port = random.randint(10000, 30000) + with socket.socket() as sock: + try: + sock.bind(("127.0.0.1", port)) + except OSError: + continue + print(port) + break +else: + raise SystemExit("could not find a free localhost port") +PY +} + +cleanup() { + local status=$? + if [[ -n "$RADAR_PID" ]] && kill -0 "$RADAR_PID" 2>/dev/null; then + kill "$RADAR_PID" 2>/dev/null || true + wait "$RADAR_PID" 2>/dev/null || true + fi + if [[ -n "$PROXY_PID" ]] && kill -0 "$PROXY_PID" 2>/dev/null; then + kill "$PROXY_PID" 2>/dev/null || true + wait "$PROXY_PID" 2>/dev/null || true + fi + if [[ -n "$DEX_CONTAINER" ]]; then + docker rm -f "$DEX_CONTAINER" >/dev/null 2>&1 || true + fi + if [[ -n "$TMP_DIR" && "$status" -eq 0 && -z "${KEEP_LOGS:-}" ]]; then + rm -rf "$TMP_DIR" + elif [[ -n "$TMP_DIR" ]]; then + echo "Logs kept in $TMP_DIR" + fi +} +trap cleanup EXIT + +check() { + local desc="$1" + local expected="$2" + local actual="$3" + if [[ "$actual" == "$expected" ]]; then + echo -e " ${GREEN}PASS${NC} $desc" + PASS=$((PASS + 1)) + else + echo -e " ${RED}FAIL${NC} $desc (expected $expected, got $actual)" + FAIL=$((FAIL + 1)) + fi +} + +check_success() { + local desc="$1" + shift + if "$@"; then + echo -e " ${GREEN}PASS${NC} $desc" + PASS=$((PASS + 1)) + else + echo -e " ${RED}FAIL${NC} $desc" + FAIL=$((FAIL + 1)) + fi +} + +fail_now() { + local message="$1" + echo -e "${RED}$message${NC}" + echo "Logs are in $TMP_DIR" + exit 1 +} + +wait_for_url() { + local url="$1" + local desc="$2" + local max_attempts="${3:-45}" + + for _ in $(seq 1 "$max_attempts"); do + if curl -sf "$url" >/dev/null 2>&1; then + echo "$desc ready." + return 0 + fi + sleep 1 + done + + return 1 +} + +absolute_form_action() { + local html_file="$1" + local base_url="$2" + + python3 - "$html_file" "$base_url" <<'PY' +import sys +from html.parser import HTMLParser +from urllib.parse import urljoin + + +class LoginFormParser(HTMLParser): + def __init__(self): + super().__init__() + self.forms = [] + self.current = None + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if tag == "form": + self.current = {"action": attrs.get("action", ""), "inputs": set()} + elif tag == "input" and self.current is not None: + name = attrs.get("name") + if name: + self.current["inputs"].add(name) + + def handle_endtag(self, tag): + if tag == "form" and self.current is not None: + self.forms.append(self.current) + self.current = None + + +with open(sys.argv[1], "r", encoding="utf-8") as f: + parser = LoginFormParser() + parser.feed(f.read()) + +for form in parser.forms: + if {"login", "password"}.issubset(form["inputs"]): + print(urljoin(sys.argv[2], form["action"])) + sys.exit(0) + +sys.exit("could not find Dex login form") +PY +} + +form_payload() { + local html_file="$1" + + python3 - "$html_file" <<'PY' +import sys +from html.parser import HTMLParser +from urllib.parse import urlencode + + +class LoginFormParser(HTMLParser): + def __init__(self): + super().__init__() + self.forms = [] + self.current = None + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if tag == "form": + self.current = [] + elif tag == "input" and self.current is not None: + name = attrs.get("name") + if name: + self.current.append((name, attrs.get("value", ""))) + + def handle_endtag(self, tag): + if tag == "form" and self.current is not None: + self.forms.append(self.current) + self.current = None + + +with open(sys.argv[1], "r", encoding="utf-8") as f: + parser = LoginFormParser() + parser.feed(f.read()) + +for inputs in parser.forms: + names = {name for name, _ in inputs} + if {"login", "password"}.issubset(names): + data = [(name, value) for name, value in inputs if name not in {"login", "password"}] + data.extend([ + ("login", "admin@example.com"), + ("password", "password"), + ]) + print(urlencode(data)) + sys.exit(0) + +sys.exit("could not find Dex login form") +PY +} + +session_cookie_from_jar() { + local jar="$1" + awk '($0 ~ /^#HttpOnly_/ || $0 !~ /^#/) && NF >= 7 && $6 == "radar_session" { print $7 }' "$jar" | tail -n1 +} + +require_cmd docker +require_cmd curl +require_cmd jq +require_cmd python3 + +if [[ ! -x "$RADAR" ]]; then + echo -e "${RED}Radar binary not found or not executable: $RADAR${NC}" + echo "Run make build first, or pass the binary path as the first argument." + exit 1 +fi + +TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/radar-oidc-split.XXXXXX") +RADAR_PORT=$(random_port) +DEX_PUBLIC_PORT=$(random_port) +DEX_INTERNAL_PORT=$(random_port) +RADAR_BASE="http://localhost:${RADAR_PORT}" +PUBLIC_ISSUER="http://localhost:${DEX_PUBLIC_PORT}/dex" +INTERNAL_ISSUER="http://127.0.0.1:${DEX_INTERNAL_PORT}/dex" +DEX_CONTAINER="radar-oidc-split-$RANDOM-$RANDOM" + +DEX_CONFIG="$TMP_DIR/dex-config.yaml" +DEX_LOG="$TMP_DIR/dex.log" +RADAR_LOG="$TMP_DIR/radar.log" +PROXY_LOG="$TMP_DIR/public-proxy.log" +COOKIE_JAR="$TMP_DIR/cookies.txt" +LOGIN_HTML="$TMP_DIR/login.html" +POST_HTML="$TMP_DIR/post-login.html" + +cat > "$DEX_CONFIG" </dev/null 2>&1 || true +docker run --rm --name "$DEX_CONTAINER" \ + -p "127.0.0.1:${DEX_INTERNAL_PORT}:5556" \ + -v "$DEX_CONFIG:/etc/dex/config.yaml:ro" \ + "$DEX_IMAGE" dex serve /etc/dex/config.yaml >"$DEX_LOG" 2>&1 & + +if ! wait_for_url "$INTERNAL_ISSUER/.well-known/openid-configuration" "Dex"; then + cat "$DEX_LOG" >&2 || true + fail_now "Dex failed to start" +fi + +echo -e "${YELLOW}Starting public issuer proxy on port $DEX_PUBLIC_PORT${NC}" +python3 - "$DEX_INTERNAL_PORT" "$DEX_PUBLIC_PORT" "$PROXY_LOG" <<'PY' & +import http.client +import sys +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlsplit + +internal_port = int(sys.argv[1]) +public_port = int(sys.argv[2]) +log_path = sys.argv[3] +blocked_paths = {"/dex/token", "/dex/keys", "/dex/userinfo"} +hop_by_hop = { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +} + + +def log(line): + with open(log_path, "a", encoding="utf-8") as f: + f.write(f"{time.time():.3f} {line}\n") + + +class Proxy(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, _fmt, *_args): + return + + def do_GET(self): + self.forward() + + def do_POST(self): + self.forward() + + def do_HEAD(self): + self.forward() + + def forward(self): + path = urlsplit(self.path).path + if path in blocked_paths: + log(f"BLOCKED {self.command} {self.path}") + body = b"blocked\n" + self.send_response(502) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(body) + return + + log(f"PUBLIC {self.command} {self.path}") + length = int(self.headers.get("Content-Length", "0") or "0") + body = self.rfile.read(length) if length else None + headers = { + key: value + for key, value in self.headers.items() + if key.lower() not in hop_by_hop + } + headers["Host"] = f"localhost:{public_port}" + + conn = http.client.HTTPConnection("127.0.0.1", internal_port, timeout=30) + try: + conn.request(self.command, self.path, body=body, headers=headers) + resp = conn.getresponse() + data = resp.read() + finally: + conn.close() + + self.send_response(resp.status, resp.reason) + for key, value in resp.getheaders(): + lower = key.lower() + if lower in hop_by_hop or lower == "content-length": + continue + self.send_header(key, value) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(data) + + +server = ThreadingHTTPServer(("127.0.0.1", public_port), Proxy) +log("READY") +server.serve_forever() +PY +PROXY_PID=$! + +if ! wait_for_url "$PUBLIC_ISSUER/.well-known/openid-configuration" "Public issuer proxy"; then + cat "$PROXY_LOG" >&2 || true + fail_now "Public issuer proxy failed to start" +fi + +echo -e "${YELLOW}Starting Radar on port $RADAR_PORT with split OIDC issuer URLs${NC}" +"$RADAR" \ + --port "$RADAR_PORT" \ + --no-browser \ + --auth-mode=oidc \ + --auth-secret=test-oidc-split-secret \ + --auth-oidc-issuer="$PUBLIC_ISSUER" \ + --auth-oidc-internal-issuer="$INTERNAL_ISSUER" \ + --auth-oidc-client-id=radar \ + --auth-oidc-client-secret=radar-secret \ + --auth-oidc-redirect-url="$RADAR_BASE/auth/callback" \ + --auth-oidc-scopes=openid,profile,email \ + >"$RADAR_LOG" 2>&1 & +RADAR_PID=$! + +if ! wait_for_url "$RADAR_BASE/api/health" "Radar"; then + cat "$RADAR_LOG" >&2 || true + fail_now "Radar failed to start" +fi + +echo "" +echo -e "${YELLOW}Test 1: unauthenticated auth/me is soft-auth${NC}" +auth_enabled=$(curl -sS "$RADAR_BASE/api/auth/me" | jq -r '.authEnabled') +check "authEnabled is true" "true" "$auth_enabled" + +echo "" +echo -e "${YELLOW}Test 2: complete Dex login through public issuer${NC}" +login_url=$(curl -sS -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L \ + -o "$LOGIN_HTML" -w '%{url_effective}' \ + "$RADAR_BASE/auth/login") + +login_action=$(absolute_form_action "$LOGIN_HTML" "$login_url") +payload=$(form_payload "$LOGIN_HTML") + +post_url=$(curl -sS -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data "$payload" \ + -o "$POST_HTML" -w '%{url_effective}' \ + "$login_action") + +check_success "browser auth path used public issuer" grep -Eq 'PUBLIC (GET|POST) /dex/auth' "$PROXY_LOG" +check "post-login redirects to Radar" "$RADAR_BASE/" "$post_url" + +session_cookie=$(session_cookie_from_jar "$COOKIE_JAR") +if [[ -z "$session_cookie" ]]; then + cat "$POST_HTML" >&2 || true + fail_now "Radar session cookie was not set" +fi + +me=$(curl -sS -H "Cookie: radar_session=${session_cookie}" "$RADAR_BASE/api/auth/me") +username=$(echo "$me" | jq -r '.username // empty') +auth_mode=$(echo "$me" | jq -r '.authMode // empty') +check "logged-in username" "admin@example.com" "$username" +check "auth mode" "oidc" "$auth_mode" + +echo "" +echo -e "${YELLOW}Test 3: server-side endpoints did not use public issuer${NC}" +if grep -Eq 'BLOCKED .* /dex/(token|keys|userinfo)' "$PROXY_LOG"; then + cat "$PROXY_LOG" >&2 + echo -e " ${RED}FAIL${NC} public token/JWKS/userinfo endpoint was used" + FAIL=$((FAIL + 1)) +else + echo -e " ${GREEN}PASS${NC} public token/JWKS/userinfo endpoints were not used" + PASS=$((PASS + 1)) +fi + +check_success "Radar authenticated the OIDC user" grep -q 'User admin@example.com authenticated' "$RADAR_LOG" + +echo "" +if [[ -n "${KEEP_LOGS:-}" ]]; then + echo "Logs kept in $TMP_DIR" +else + echo "Temp logs: $TMP_DIR (removed on success; set KEEP_LOGS=1 to keep)" +fi +echo -e "Passed: ${GREEN}$PASS${NC}, Failed: ${RED}$FAIL${NC}" + +if [[ "$FAIL" -gt 0 ]]; then + exit 1 +fi