Add OIDC CA bundle support
This commit is contained in:
parent
cd31bdece1
commit
5dbaf7c596
9 changed files with 230 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
25
docs/OIDC.md
25
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. |
|
||||
|
||||
|
|
|
|||
|
|
@ -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'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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
73
internal/api/oidc_service_test.go
Normal file
73
internal/api/oidc_service_test.go
Normal 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
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue