diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 12761ac..ad38713 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -560,6 +560,7 @@ Set these environment variables to manage single sign-on without using the UI. W - `OIDC_ALLOWED_GROUPS` - Allowed group names (comma/space separated) - `OIDC_ALLOWED_DOMAINS` - Allowed email domains - `OIDC_ALLOWED_EMAILS` - Explicit email allowlist +- `OIDC_CA_BUNDLE` - Path to a PEM file containing additional root CAs for your IdP (for self-signed/internal issuers) - `PULSE_PUBLIC_URL` **(strongly recommended)** - The externally reachable base URL Pulse should advertise. This is used to generate the default redirect URI. If you expose Pulse on multiple hostnames, list each one in your IdP configuration because OIDC callbacks must match exactly. > **Authentik note:** Assign an RSA signing key to the application so ID tokens use `RS256`. Without it Authentik falls back to `HS256`, which Pulse rejects. See [Authentik setup details](OIDC.md#authentik) for the exact menu path. diff --git a/docs/OIDC.md b/docs/OIDC.md index 4dc8062..d1ecf71 100644 --- a/docs/OIDC.md +++ b/docs/OIDC.md @@ -113,6 +113,30 @@ This matches the bundled Dex mock server: All configuration can be provided via environment variables (see [`docs/CONFIGURATION.md`](./CONFIGURATION.md#oidc-variables-optional-overrides)). When any `OIDC_*` variable is present the UI is placed in read-only mode and values must be changed from the deployment configuration instead. +## Self-signed / private CA issuers + +Pulse validates OIDC discovery, JWKS fetches, and token exchanges with the container trust store. For self-signed or private PKI issuers, mount your IdP root CA into the container and point `OIDC_CA_BUNDLE` at that PEM file. + +Example Helm override: + +```yaml +server: + extraVolumes: + - name: oidc-ca + secret: + secretName: oidc-ca + extraVolumeMounts: + - name: oidc-ca + mountPath: /etc/ssl/certs/oidc-ca.pem + subPath: oidc-ca.pem + readOnly: true + extraEnv: + - name: OIDC_CA_BUNDLE + value: /etc/ssl/certs/oidc-ca.pem +``` + +This keeps TLS verification enabled while trusting your internal CA. Avoid disabling verification; use a CA bundle instead. + ## Login Flow - The login screen shows a **Continue with Single Sign-On** button when OIDC is enabled. @@ -130,6 +154,7 @@ All configuration can be provided via environment variables (see [`docs/CONFIGUR | Users see `single sign-on failed` | Check `journalctl -u pulse.service` for detailed OIDC audit logs. Common causes include mismatched client IDs, incorrect redirect URLs, or group/domain restrictions. | | UI shows "OIDC settings are managed by environment variables" | Remove the relevant `OIDC_*` environment variables or update them directly in your deployment. | | Provider discovery fails | Verify the issuer URL is reachable from the Pulse server and returns valid OIDC discovery metadata at `/.well-known/openid-configuration`. | +| `tls: failed to verify certificate: x509: certificate signed by unknown authority` | Mount your IdP CA into the container and set `OIDC_CA_BUNDLE` to that PEM path (see self-signed section above). | | Group restrictions not working | Enable debug logging to see which groups the IdP is sending and verify the `groups_claim` setting matches your IdP's claim name. | | Auto-redirect to OIDC when password auth still enabled | This is expected behavior when OIDC is enabled. Users can still use password auth by clicking "Use your admin credentials to sign in below" on the login page. To disable auto-redirect, comment out the auto-redirect code in the frontend. | diff --git a/frontend-modern/src/components/Settings/OIDCPanel.tsx b/frontend-modern/src/components/Settings/OIDCPanel.tsx index 908fb21..a6150d9 100644 --- a/frontend-modern/src/components/Settings/OIDCPanel.tsx +++ b/frontend-modern/src/components/Settings/OIDCPanel.tsx @@ -20,6 +20,7 @@ interface OIDCConfigResponse { allowedGroups: string[]; allowedDomains: string[]; allowedEmails: string[]; + caBundle?: string; clientSecretSet: boolean; envOverrides?: Record; defaultRedirect: string; @@ -55,6 +56,7 @@ export const OIDCPanel: Component = (props) => { allowedGroups: '', allowedDomains: '', allowedEmails: '', + caBundle: '', clientSecret: '', clearSecret: false, }); @@ -79,6 +81,7 @@ export const OIDCPanel: Component = (props) => { allowedGroups: '', allowedDomains: '', allowedEmails: '', + caBundle: '', clientSecret: '', clearSecret: false, }); @@ -98,6 +101,7 @@ export const OIDCPanel: Component = (props) => { allowedGroups: listToString(data.allowedGroups), allowedDomains: listToString(data.allowedDomains), allowedEmails: listToString(data.allowedEmails), + caBundle: data.caBundle || '', clientSecret: '', clearSecret: false, }); @@ -150,6 +154,7 @@ export const OIDCPanel: Component = (props) => { allowedGroups: splitList(form.allowedGroups), allowedDomains: splitList(form.allowedDomains), allowedEmails: splitList(form.allowedEmails), + caBundle: form.caBundle.trim(), }; if (form.clientSecret.trim() !== '') { @@ -375,6 +380,21 @@ export const OIDCPanel: Component = (props) => { />

Space-separated list of scopes requested during login.

+
+ + setForm('caBundle', event.currentTarget.value)} + placeholder="/etc/ssl/certs/oidc-ca.pem" + class={controlClass()} + disabled={isEnvLocked() || saving()} + /> +

+ Optional path to a PEM bundle containing your IdP's root certificates. Mount + the file into the container; leave blank to use the system trust store. +

+
0 { cfg.OIDC.MergeFromEnv(oidcEnv) } diff --git a/internal/config/oidc.go b/internal/config/oidc.go index 2b49037..7f626d1 100644 --- a/internal/config/oidc.go +++ b/internal/config/oidc.go @@ -27,6 +27,7 @@ type OIDCConfig struct { AllowedGroups []string `json:"allowedGroups,omitempty"` AllowedDomains []string `json:"allowedDomains,omitempty"` AllowedEmails []string `json:"allowedEmails,omitempty"` + CABundle string `json:"caBundle,omitempty"` EnvOverrides map[string]bool `json:"-"` } @@ -48,6 +49,7 @@ func (c *OIDCConfig) Clone() *OIDCConfig { clone.AllowedGroups = append([]string{}, c.AllowedGroups...) clone.AllowedDomains = append([]string{}, c.AllowedDomains...) clone.AllowedEmails = append([]string{}, c.AllowedEmails...) + clone.CABundle = c.CABundle if c.EnvOverrides != nil { clone.EnvOverrides = make(map[string]bool, len(c.EnvOverrides)) for k, v := range c.EnvOverrides { @@ -63,6 +65,8 @@ func (c *OIDCConfig) ApplyDefaults(publicURL string) { return } + c.CABundle = strings.TrimSpace(c.CABundle) + if len(c.Scopes) == 0 { c.Scopes = append([]string{}, defaultOIDCScopes...) } else { @@ -227,4 +231,8 @@ func (c *OIDCConfig) MergeFromEnv(env map[string]string) { c.AllowedEmails = parseDelimited(val) c.EnvOverrides["allowedEmails"] = true } + if val, ok := env["OIDC_CA_BUNDLE"]; ok { + c.CABundle = val + c.EnvOverrides["caBundle"] = true + } }