Skip to content

Commit 8fa97de

Browse files
committed
feat(oidc): optional TLS CA certificate pinning for the IdP
OIDC discovery, JWKS fetch, and token exchange used the system TLS trust store with no pinning. A CA compromise in the system store would let an attacker MITM the IdP, serve a forged JWKS, and sign an ID token for any subject — authenticating as any operator (full mesh compromise). High bar (CA compromise + network position), but the blast radius justifies defence-in-depth. Add an optional oidc.tls_ca_cert config field pointing to a PEM CA bundle. When set, NewOIDC builds a dedicated *http.Client whose RootCAs is that bundle and wires it through oidc.ClientContext before provider discovery; go-oidc reuses the same client for the JWKS keyset, so discovery and id_token verification are both pinned. The callback wires the same client into the token exchange. The system trust store is left intact for every other connection. A missing or malformed CA file fails NewOIDC loudly rather than silently falling back to the system store. Closes #264
1 parent 183cd78 commit 8fa97de

5 files changed

Lines changed: 252 additions & 20 deletions

File tree

configs/server.example.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,9 @@ allow_self_registration: false
126126
# # set `default_role: "admin"` AND at least one allowlist entry (the
127127
# # two-field footprint is the point).
128128
# default_role: "user" # role for newly-federated users
129+
# # Optional: pin the IdP's TLS to a specific CA bundle (#264). When set,
130+
# # OIDC discovery, JWKS fetch, and token exchange trust only this PEM bundle,
131+
# # so a CA compromise in the system trust store cannot MITM the IdP and forge
132+
# # ID tokens. Leave unset to use the system trust store. Only affects IdP
133+
# # traffic; every other connection still uses the system store.
134+
# tls_ca_cert: /etc/nebula-mgmt/oidc-ca.pem

internal/config/server.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,14 @@ type OIDCConfig struct {
379379
// or send it in a shape HandleCallback can't decode (numeric,
380380
// nested object, etc). emailVerifiedRequired() resolves nil → true.
381381
RequireEmailVerified *bool `yaml:"require_email_verified,omitempty"`
382+
383+
// TLSCACert optionally pins the IdP's TLS trust to a PEM CA bundle at this
384+
// path (#264). When set, OIDC discovery, JWKS fetch, and token exchange use
385+
// a dedicated HTTP client whose RootCAs is this bundle, so a CA compromise
386+
// in the system trust store cannot MITM the IdP. Empty = use the system
387+
// trust store (default). The system store is untouched for every other
388+
// connection.
389+
TLSCACert string `yaml:"tls_ca_cert,omitempty"`
382390
}
383391

384392
// EmailVerifiedRequired reports whether HandleCallback must enforce the

internal/web/oidc.go

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@ package web
33
import (
44
"context"
55
"crypto/rand"
6+
"crypto/tls"
7+
"crypto/x509"
68
"encoding/hex"
79
"errors"
810
"fmt"
911
"log/slog"
1012
"net/http"
13+
"os"
1114
"strings"
1215
"sync"
1316
"time"
@@ -56,6 +59,12 @@ type OIDC struct {
5659
// for user-role operators without coupling OIDC to Web struct.
5760
// If nil, auto-provision is skipped.
5861
provisionCA func(ctx context.Context, op *models.Operator) error
62+
63+
// httpClient is the CA-pinned HTTP client used for all IdP traffic when
64+
// oidc.tls_ca_cert is configured (#264). nil means the system trust store
65+
// is used (default). When set it is wired into discovery/JWKS at provider
66+
// creation and into the callback's token exchange via oidc.ClientContext.
67+
httpClient *http.Client
5968
}
6069

6170
// SetCookieSecure controls the Secure attribute on the OIDC state cookie.
@@ -77,6 +86,21 @@ func NewOIDC(ctx context.Context, cfg *config.OIDCConfig, s store.Store, sm *Ses
7786
if cfg.Issuer == "" || cfg.ClientID == "" || cfg.RedirectURL == "" {
7887
return nil, fmt.Errorf("oidc: issuer, client_id, redirect_url are required")
7988
}
89+
// When tls_ca_cert is configured, pin all IdP traffic to that CA bundle by
90+
// running discovery (and later JWKS + token exchange) over a dedicated HTTP
91+
// client. go-oidc stores the client from the context at provider creation
92+
// and reuses it for the JWKS keyset, so this one ClientContext covers
93+
// discovery and id_token verification; the callback wires the same client
94+
// into the token exchange (#264).
95+
var httpClient *http.Client
96+
if cfg.TLSCACert != "" {
97+
c, err := oidcHTTPClientWithCA(cfg.TLSCACert)
98+
if err != nil {
99+
return nil, fmt.Errorf("oidc: %w", err)
100+
}
101+
httpClient = c
102+
ctx = oidc.ClientContext(ctx, httpClient)
103+
}
80104
provider, err := oidc.NewProvider(ctx, cfg.Issuer)
81105
if err != nil {
82106
return nil, fmt.Errorf("oidc discovery: %w", err)
@@ -93,14 +117,38 @@ func NewOIDC(ctx context.Context, cfg *config.OIDCConfig, s store.Store, sm *Ses
93117
Scopes: scopes,
94118
}
95119
return &OIDC{
96-
cfg: *cfg,
97-
provider: provider,
98-
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
99-
oauth: oc,
100-
store: s,
101-
session: sm,
102-
logger: logger,
103-
states: make(map[string]time.Time),
120+
cfg: *cfg,
121+
provider: provider,
122+
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
123+
oauth: oc,
124+
store: s,
125+
session: sm,
126+
logger: logger,
127+
states: make(map[string]time.Time),
128+
httpClient: httpClient,
129+
}, nil
130+
}
131+
132+
// oidcHTTPClientWithCA builds an HTTP client that trusts only the CA bundle at
133+
// caPath for TLS, used to pin the IdP connection (#264). It fails loudly when
134+
// the file is unreadable or holds no valid PEM certificate rather than silently
135+
// falling back to the system trust store.
136+
func oidcHTTPClientWithCA(caPath string) (*http.Client, error) {
137+
pemBytes, err := os.ReadFile(caPath) // #nosec G304 -- operator-supplied trusted config path
138+
if err != nil {
139+
return nil, fmt.Errorf("read tls_ca_cert %q: %w", caPath, err)
140+
}
141+
pool := x509.NewCertPool()
142+
if !pool.AppendCertsFromPEM(pemBytes) {
143+
return nil, fmt.Errorf("tls_ca_cert %q: no valid PEM certificates", caPath)
144+
}
145+
return &http.Client{
146+
Transport: &http.Transport{
147+
TLSClientConfig: &tls.Config{
148+
RootCAs: pool,
149+
MinVersion: tls.VersionTLS12,
150+
},
151+
},
104152
}, nil
105153
}
106154

@@ -182,7 +230,14 @@ func (o *OIDC) HandleCallback(rw http.ResponseWriter, r *http.Request) {
182230
return
183231
}
184232

185-
tok, err := o.oauth.Exchange(r.Context(), code)
233+
// Route token exchange (and id_token verification) through the CA-pinned
234+
// client when tls_ca_cert is configured, so the callback honors the same
235+
// trust anchor as discovery (#264). Without pinning this is just r.Context().
236+
ctx := r.Context()
237+
if o.httpClient != nil {
238+
ctx = oidc.ClientContext(ctx, o.httpClient)
239+
}
240+
tok, err := o.oauth.Exchange(ctx, code)
186241
if err != nil {
187242
o.logger.Error("oidc exchange", "error", err)
188243
http.Error(rw, "oidc token exchange failed", http.StatusBadGateway)
@@ -193,7 +248,7 @@ func (o *OIDC) HandleCallback(rw http.ResponseWriter, r *http.Request) {
193248
http.Error(rw, "oidc response missing id_token", http.StatusBadGateway)
194249
return
195250
}
196-
idToken, err := o.verifier.Verify(r.Context(), rawID)
251+
idToken, err := o.verifier.Verify(ctx, rawID)
197252
if err != nil {
198253
http.Error(rw, "oidc id_token verification failed: "+err.Error(), http.StatusBadGateway)
199254
return

internal/web/oidc_testhelper_test.go

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,9 @@ type mockIDP struct {
6262
tokenStatus int
6363
}
6464

65-
// setupOIDCServer starts a mock IdP and returns a handle for configuring it.
66-
// Callers must call t.Cleanup or defer Close.
67-
func setupOIDCServer(t *testing.T) *mockIDP {
65+
// newMockIDP builds a mock IdP (signing key + handlers) without starting a
66+
// server, so callers can choose a plaintext or TLS httptest server.
67+
func newMockIDP(t *testing.T) *mockIDP {
6868
t.Helper()
6969

7070
key, err := rsa.GenerateKey(rand.Reader, 2048)
@@ -88,19 +88,42 @@ func setupOIDCServer(t *testing.T) *mockIDP {
8888
return nil
8989
}
9090

91-
idp := &mockIDP{
91+
return &mockIDP{
9292
signer: signer,
9393
publicKey: &key.PublicKey,
9494
kid: kid,
9595
}
96+
}
9697

98+
// routes wires the five discovery/JWKS/token/userinfo/auth endpoints the
99+
// relying-party flow needs onto a fresh mux.
100+
func (m *mockIDP) routes() http.Handler {
97101
mux := http.NewServeMux()
98-
mux.HandleFunc("/.well-known/openid-configuration", idp.handleDiscovery)
99-
mux.HandleFunc("/keys", idp.handleJWKS)
100-
mux.HandleFunc("/auth", idp.handleAuth)
101-
mux.HandleFunc("/token", idp.handleToken)
102-
mux.HandleFunc("/userinfo", idp.handleUserinfo)
103-
idp.server = httptest.NewServer(mux)
102+
mux.HandleFunc("/.well-known/openid-configuration", m.handleDiscovery)
103+
mux.HandleFunc("/keys", m.handleJWKS)
104+
mux.HandleFunc("/auth", m.handleAuth)
105+
mux.HandleFunc("/token", m.handleToken)
106+
mux.HandleFunc("/userinfo", m.handleUserinfo)
107+
return mux
108+
}
109+
110+
// setupOIDCServer starts a plaintext-HTTP mock IdP and returns a handle for
111+
// configuring it. Callers must call t.Cleanup or defer Close.
112+
func setupOIDCServer(t *testing.T) *mockIDP {
113+
t.Helper()
114+
idp := newMockIDP(t)
115+
idp.server = httptest.NewServer(idp.routes())
116+
t.Cleanup(idp.Close)
117+
return idp
118+
}
119+
120+
// setupOIDCServerTLS starts the same mock IdP over TLS (self-signed cert), used
121+
// to exercise oidc.tls_ca_cert certificate pinning (#264). The server's cert is
122+
// reachable via idp.server.Certificate().
123+
func setupOIDCServerTLS(t *testing.T) *mockIDP {
124+
t.Helper()
125+
idp := newMockIDP(t)
126+
idp.server = httptest.NewTLSServer(idp.routes())
104127
t.Cleanup(idp.Close)
105128
return idp
106129
}

internal/web/oidc_tls_pin_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package web
2+
3+
import (
4+
"context"
5+
"encoding/pem"
6+
"io"
7+
"log/slog"
8+
"net/http"
9+
"os"
10+
"path/filepath"
11+
"testing"
12+
13+
"github.com/forgekeep/nebula-mesh/internal/config"
14+
)
15+
16+
// writeIDPCAPEM writes the TLS mock IdP's self-signed certificate to a PEM file
17+
// and returns its path, for use as oidc.tls_ca_cert.
18+
func writeIDPCAPEM(t *testing.T, idp *mockIDP) string {
19+
t.Helper()
20+
cert := idp.server.Certificate()
21+
if cert == nil {
22+
t.Fatal("TLS mock IdP has no certificate")
23+
}
24+
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
25+
path := filepath.Join(t.TempDir(), "idp-ca.pem")
26+
if err := os.WriteFile(path, pemBytes, 0o600); err != nil {
27+
t.Fatal(err)
28+
}
29+
return path
30+
}
31+
32+
// TestOIDCTLSPin_DiscoverySucceedsWithPin verifies that pinning the IdP's CA
33+
// lets discovery succeed against an otherwise-untrusted self-signed TLS IdP.
34+
func TestOIDCTLSPin_DiscoverySucceedsWithPin(t *testing.T) {
35+
_, s := newTestWeb(t)
36+
idp := setupOIDCServerTLS(t)
37+
caPath := writeIDPCAPEM(t, idp)
38+
39+
o := newOIDCFromMock(t, idp, s, config.OIDCConfig{
40+
AllowedEmails: []string{"alice@example.com"},
41+
DefaultRole: "user",
42+
TLSCACert: caPath,
43+
})
44+
if o == nil {
45+
t.Fatal("expected non-nil OIDC with valid CA pin")
46+
}
47+
if o.httpClient == nil {
48+
t.Error("expected pinned httpClient to be set when tls_ca_cert is configured")
49+
}
50+
}
51+
52+
// TestOIDCTLSPin_DiscoveryFailsWithoutPin verifies that without pinning, the
53+
// self-signed TLS IdP is rejected by the system trust store at discovery.
54+
func TestOIDCTLSPin_DiscoveryFailsWithoutPin(t *testing.T) {
55+
_, s := newTestWeb(t)
56+
idp := setupOIDCServerTLS(t)
57+
58+
cfg := config.OIDCConfig{
59+
Enabled: true,
60+
Issuer: idp.Issuer(),
61+
ClientID: "test-client",
62+
ClientSecret: "test-secret",
63+
RedirectURL: "https://nebula-mesh.test/ui/oidc/callback",
64+
AllowedEmails: []string{"alice@example.com"},
65+
DefaultRole: "user",
66+
// No TLSCACert: the self-signed cert is not trusted.
67+
}
68+
sm := NewSessionManager(s)
69+
_, err := NewOIDC(context.Background(), &cfg, s, sm, slog.New(slog.NewTextHandler(io.Discard, nil)))
70+
if err == nil {
71+
t.Fatal("expected discovery to fail against untrusted self-signed IdP without pinning")
72+
}
73+
}
74+
75+
// TestOIDCTLSPin_BadCAFile verifies a missing or malformed CA file fails NewOIDC
76+
// loudly instead of silently falling back to the system store.
77+
func TestOIDCTLSPin_BadCAFile(t *testing.T) {
78+
_, s := newTestWeb(t)
79+
idp := setupOIDCServerTLS(t)
80+
sm := NewSessionManager(s)
81+
82+
base := config.OIDCConfig{
83+
Enabled: true,
84+
Issuer: idp.Issuer(),
85+
ClientID: "test-client",
86+
ClientSecret: "test-secret",
87+
RedirectURL: "https://nebula-mesh.test/ui/oidc/callback",
88+
AllowedEmails: []string{"alice@example.com"},
89+
DefaultRole: "user",
90+
}
91+
92+
t.Run("missing file", func(t *testing.T) {
93+
cfg := base
94+
cfg.TLSCACert = filepath.Join(t.TempDir(), "does-not-exist.pem")
95+
if _, err := NewOIDC(context.Background(), &cfg, s, sm, slog.New(slog.NewTextHandler(io.Discard, nil))); err == nil {
96+
t.Fatal("expected error for missing CA file")
97+
}
98+
})
99+
100+
t.Run("garbage file", func(t *testing.T) {
101+
bad := filepath.Join(t.TempDir(), "garbage.pem")
102+
if err := os.WriteFile(bad, []byte("not a pem certificate"), 0o600); err != nil {
103+
t.Fatal(err)
104+
}
105+
cfg := base
106+
cfg.TLSCACert = bad
107+
if _, err := NewOIDC(context.Background(), &cfg, s, sm, slog.New(slog.NewTextHandler(io.Discard, nil))); err == nil {
108+
t.Fatal("expected error for non-PEM CA file")
109+
}
110+
})
111+
}
112+
113+
// TestOIDCTLSPin_FullCallback drives the full callback flow (token exchange +
114+
// id_token verification) against the TLS IdP with pinning, proving the pinned
115+
// client is used end to end, not just for discovery.
116+
func TestOIDCTLSPin_FullCallback(t *testing.T) {
117+
_, s := newTestWeb(t)
118+
idp := setupOIDCServerTLS(t)
119+
caPath := writeIDPCAPEM(t, idp)
120+
121+
o := newOIDCFromMock(t, idp, s, config.OIDCConfig{
122+
AllowedEmails: []string{"alice@example.com"},
123+
DefaultRole: "user",
124+
TLSCACert: caPath,
125+
})
126+
127+
idp.NextIDToken(map[string]any{
128+
"sub": "alice-sub",
129+
"aud": "test-client",
130+
"email": "alice@example.com",
131+
"email_verified": true,
132+
"preferred_username": "alice",
133+
"name": "Alice",
134+
})
135+
136+
rec := driveCallback(t, o, "state-tlspin", "code-1")
137+
if rec.Code != http.StatusSeeOther {
138+
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
139+
}
140+
}

0 commit comments

Comments
 (0)