Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions cmd/explorer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions deploy/helm/radar/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
5 changes: 5 additions & 0 deletions deploy/helm/radar/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
10 changes: 10 additions & 0 deletions deploy/helm/radar/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
30 changes: 30 additions & 0 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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` | — |
Expand Down
205 changes: 194 additions & 11 deletions internal/auth/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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) {
Expand Down Expand Up @@ -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
}
Expand All @@ -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")
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issuer slash mismatch breaks split OIDC

Medium Severity

In the custom OIDC metadata path, discovery issuer validation uses a strict string compare against OIDCIssuer, then forces that configured value as the verifier issuer. When internalIssuerURL or endpoint overrides are set, a trailing-slash mismatch between Helm/docs-style issuerURL and the IdP discovery issuer can fail startup or reject valid ID tokens, unlike the default oidc.NewProvider path.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 02b600c. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this one is not a bug. Upstream go-oidc has the same behavior: NewProvider trims the configured issuer only to build /.well-known/openid-configuration, then compares the discovered issuer exactly against the configured issuer before constructing the provider. The custom path here is intentionally preserving that exact issuer validation, and replaceOIDCURLBase already trims base URLs for endpoint rewriting so trailing slash differences on internalIssuerURL do not break the derived token/JWKS/userinfo URLs.

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
Expand Down
Loading
Loading