Add OIDC CA bundle support

This commit is contained in:
rcourtman 2025-11-22 09:44:03 +00:00
parent cd31bdece1
commit 5dbaf7c596
9 changed files with 230 additions and 0 deletions

View file

@ -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.

View file

@ -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. |

View file

@ -20,6 +20,7 @@ interface OIDCConfigResponse {
allowedGroups: string[];
allowedDomains: string[];
allowedEmails: string[];
caBundle?: string;
clientSecretSet: boolean;
envOverrides?: Record<string, boolean>;
defaultRedirect: string;
@ -55,6 +56,7 @@ export const OIDCPanel: Component<Props> = (props) => {
allowedGroups: '',
allowedDomains: '',
allowedEmails: '',
caBundle: '',
clientSecret: '',
clearSecret: false,
});
@ -79,6 +81,7 @@ export const OIDCPanel: Component<Props> = (props) => {
allowedGroups: '',
allowedDomains: '',
allowedEmails: '',
caBundle: '',
clientSecret: '',
clearSecret: false,
});
@ -98,6 +101,7 @@ export const OIDCPanel: Component<Props> = (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> = (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> = (props) => {
/>
<p class={formHelpText}>Space-separated list of scopes requested during login.</p>
</div>
<div class={formField}>
<label class={labelClass()}>CA bundle path</label>
<input
type="text"
value={form.caBundle}
onInput={(event) => setForm('caBundle', event.currentTarget.value)}
placeholder="/etc/ssl/certs/oidc-ca.pem"
class={controlClass()}
disabled={isEnvLocked() || saving()}
/>
<p class={formHelpText}>
Optional path to a PEM bundle containing your IdP&apos;s root certificates. Mount
the file into the container; leave blank to use the system trust store.
</p>
</div>
<div class={formField}>
<label class={labelClass()}>Username claim</label>
<input

View file

@ -119,6 +119,7 @@ func (r *Router) handleOIDCCallback(w http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 15*time.Second)
defer cancel()
ctx = service.contextWithHTTPClient(ctx)
token, err := service.exchangeCode(ctx, code, entry)
if err != nil {

View file

@ -4,9 +4,14 @@ import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"net/http"
"os"
"strings"
"sync"
"time"
@ -26,6 +31,7 @@ type OIDCService struct {
oauth2Cfg *oauth2.Config
verifier *oidc.IDTokenVerifier
stateStore *oidcStateStore
httpClient *http.Client
}
type oidcSnapshot struct {
@ -34,6 +40,8 @@ type oidcSnapshot struct {
clientSecret string
redirectURL string
scopes []string
caBundle string
caBundleHash string
}
// NewOIDCService fetches provider metadata and prepares helper structures.
@ -49,8 +57,16 @@ func NewOIDCService(ctx context.Context, cfg *config.OIDCConfig) (*OIDCService,
Str("issuer", cfg.IssuerURL).
Str("redirect_url", cfg.RedirectURL).
Strs("scopes", cfg.Scopes).
Str("ca_bundle", cfg.CABundle).
Msg("Initializing OIDC provider")
httpClient, caHash, err := newOIDCHTTPClient(cfg.CABundle)
if err != nil {
return nil, fmt.Errorf("failed to build OIDC HTTP client: %w", err)
}
ctx = oidc.ClientContext(ctx, httpClient)
provider, err := oidc.NewProvider(ctx, cfg.IssuerURL)
if err != nil {
return nil, fmt.Errorf("failed to discover OIDC provider: %w", err)
@ -78,6 +94,8 @@ func NewOIDCService(ctx context.Context, cfg *config.OIDCConfig) (*OIDCService,
clientSecret: cfg.ClientSecret,
redirectURL: cfg.RedirectURL,
scopes: append([]string{}, cfg.Scopes...),
caBundle: cfg.CABundle,
caBundleHash: caHash,
}
service := &OIDCService{
@ -86,6 +104,7 @@ func NewOIDCService(ctx context.Context, cfg *config.OIDCConfig) (*OIDCService,
oauth2Cfg: oauth2Cfg,
verifier: verifier,
stateStore: newOIDCStateStore(),
httpClient: httpClient,
}
return service, nil
@ -109,6 +128,18 @@ func (s *OIDCService) Matches(cfg *config.OIDCConfig) bool {
if s.snapshot.redirectURL != cfg.RedirectURL {
return false
}
if s.snapshot.caBundle != cfg.CABundle {
return false
}
if cfg.CABundle != "" {
currentHash, err := hashCABundle(cfg.CABundle)
if err != nil {
return false
}
if s.snapshot.caBundleHash != currentHash {
return false
}
}
if len(s.snapshot.scopes) != len(cfg.Scopes) {
return false
}
@ -164,6 +195,7 @@ func (s *OIDCService) authCodeURL(state string, entry *oidcStateEntry) string {
}
func (s *OIDCService) exchangeCode(ctx context.Context, code string, entry *oidcStateEntry) (*oauth2.Token, error) {
ctx = s.contextWithHTTPClient(ctx)
opts := []oauth2.AuthCodeOption{}
if entry.CodeVerifier != "" {
opts = append(opts, oauth2.SetAuthURLParam("code_verifier", entry.CodeVerifier))
@ -171,6 +203,66 @@ func (s *OIDCService) exchangeCode(ctx context.Context, code string, entry *oidc
return s.oauth2Cfg.Exchange(ctx, code, opts...)
}
func (s *OIDCService) contextWithHTTPClient(ctx context.Context) context.Context {
if s.httpClient == nil {
return ctx
}
return oidc.ClientContext(ctx, s.httpClient)
}
func newOIDCHTTPClient(caBundle string) (*http.Client, string, error) {
transport, ok := http.DefaultTransport.(*http.Transport)
var clone *http.Transport
if ok && transport != nil {
clone = transport.Clone()
} else {
clone = &http.Transport{
Proxy: http.ProxyFromEnvironment,
}
}
if strings.TrimSpace(caBundle) == "" {
return &http.Client{Transport: clone}, "", nil
}
caData, err := os.ReadFile(caBundle)
if err != nil {
return nil, "", fmt.Errorf("failed to read OIDC CA bundle: %w", err)
}
pool, err := x509.SystemCertPool()
if err != nil || pool == nil {
pool = x509.NewCertPool()
}
if ok := pool.AppendCertsFromPEM(caData); !ok {
return nil, "", fmt.Errorf("OIDC CA bundle does not contain any certificates")
}
if clone.TLSClientConfig == nil {
clone.TLSClientConfig = &tls.Config{}
}
clone.TLSClientConfig.MinVersion = tls.VersionTLS12
clone.TLSClientConfig.RootCAs = pool
sum := sha256.Sum256(caData)
caHash := fmt.Sprintf("%x", sum[:])
return &http.Client{Transport: clone}, caHash, nil
}
func hashCABundle(path string) (string, error) {
if strings.TrimSpace(path) == "" {
return "", nil
}
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
sum := sha256.Sum256(data)
return fmt.Sprintf("%x", sum[:]), nil
}
// oidcStateStore keeps short-lived authorization state tokens.
type oidcStateStore struct {
mu sync.RWMutex

View file

@ -0,0 +1,73 @@
package api
import (
"crypto/x509"
"encoding/pem"
"errors"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
)
func TestNewOIDCHTTPClient_WithCustomCABundle(t *testing.T) {
// Self-signed TLS server should be rejected by default client
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
// Default trust store should fail
defaultClient, _, err := newOIDCHTTPClient("")
if err != nil {
t.Fatalf("failed to build default client: %v", err)
}
defaultClient.Timeout = testHTTPTimeout
if _, err := defaultClient.Get(server.URL); err == nil {
t.Fatalf("expected self-signed cert failure, got nil error")
} else {
var certErr x509.UnknownAuthorityError
if !errors.As(err, &certErr) {
t.Fatalf("expected unknown authority error, got: %v", err)
}
}
// Write server certificate to a temp CA bundle
tempFile, err := os.CreateTemp("", "oidc-ca-*.pem")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(tempFile.Name())
defer tempFile.Close()
certDER := server.TLS.Certificates[0].Certificate[0]
cert, err := x509.ParseCertificate(certDER)
if err != nil {
t.Fatalf("failed to parse server certificate: %v", err)
}
if err := pem.Encode(tempFile, &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}); err != nil {
t.Fatalf("failed to write temp CA bundle: %v", err)
}
// Client with custom CA bundle should succeed
customClient, _, err := newOIDCHTTPClient(tempFile.Name())
if err != nil {
t.Fatalf("failed to build custom client: %v", err)
}
customClient.Timeout = testHTTPTimeout
if resp, err := customClient.Get(server.URL); err != nil {
t.Fatalf("expected successful GET with custom CA bundle, got error: %v", err)
} else {
resp.Body.Close()
}
}
func TestNewOIDCHTTPClient_InvalidBundle(t *testing.T) {
client, _, err := newOIDCHTTPClient("/nonexistent/oidc-ca.pem")
if err == nil {
t.Fatalf("expected error for missing CA bundle, got client: %+v", client)
}
}
const testHTTPTimeout = 2 * time.Second

View file

@ -54,6 +54,7 @@ func (r *Router) handleUpdateOIDCConfig(w http.ResponseWriter, req *http.Request
AllowedDomains []string `json:"allowedDomains"`
AllowedEmails []string `json:"allowedEmails"`
ClearClientSecret bool `json:"clearClientSecret"`
CABundle *string `json:"caBundle"`
}
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
@ -74,6 +75,7 @@ func (r *Router) handleUpdateOIDCConfig(w http.ResponseWriter, req *http.Request
AllowedGroups: append([]string{}, payload.AllowedGroups...),
AllowedDomains: append([]string{}, payload.AllowedDomains...),
AllowedEmails: append([]string{}, payload.AllowedEmails...),
CABundle: strings.TrimSpace(cfg.CABundle),
EnvOverrides: make(map[string]bool),
}
@ -85,6 +87,9 @@ func (r *Router) handleUpdateOIDCConfig(w http.ResponseWriter, req *http.Request
if payload.ClientSecret != nil {
updated.ClientSecret = strings.TrimSpace(*payload.ClientSecret)
}
if payload.CABundle != nil {
updated.CABundle = strings.TrimSpace(*payload.CABundle)
}
updated.ApplyDefaults(r.config.PublicURL)
@ -122,6 +127,7 @@ type oidcResponse struct {
AllowedGroups []string `json:"allowedGroups"`
AllowedDomains []string `json:"allowedDomains"`
AllowedEmails []string `json:"allowedEmails"`
CABundle string `json:"caBundle"`
ClientSecretSet bool `json:"clientSecretSet"`
DefaultRedirect string `json:"defaultRedirect"`
EnvOverrides map[string]bool `json:"envOverrides,omitempty"`
@ -146,6 +152,7 @@ func makeOIDCResponse(cfg *config.OIDCConfig, publicURL string) oidcResponse {
AllowedGroups: append([]string{}, cfg.AllowedGroups...),
AllowedDomains: append([]string{}, cfg.AllowedDomains...),
AllowedEmails: append([]string{}, cfg.AllowedEmails...),
CABundle: cfg.CABundle,
ClientSecretSet: cfg.ClientSecret != "",
DefaultRedirect: config.DefaultRedirectURL(publicURL),
}

View file

@ -1098,6 +1098,9 @@ func Load() (*Config, error) {
if val := os.Getenv("OIDC_ALLOWED_EMAILS"); val != "" {
oidcEnv["OIDC_ALLOWED_EMAILS"] = val
}
if val := os.Getenv("OIDC_CA_BUNDLE"); val != "" {
oidcEnv["OIDC_CA_BUNDLE"] = val
}
if len(oidcEnv) > 0 {
cfg.OIDC.MergeFromEnv(oidcEnv)
}

View file

@ -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
}
}