Feat v0.6.7 native mTLS (#42)
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-go-pg (pull_request) Successful in 2m44s
CI/CD / test-sqlite (pull_request) Successful in 2m50s
CI/CD / build-and-deploy (pull_request) Successful in 1m4s

End-to-end mutual TLS without a reverse proxy. Go binary terminates
TLS itself via ListenAndServeTLS. TLS_MODE config (none/server/mtls)
is independent of AUTH_MODE.

- MTLSNativeProvider reads PeerCertificates directly (no header trust)
- Shared helpers extracted to mtls_helpers.go (ParseDN, FingerprintCert)
- MTLSProvider renamed to MTLSProxyProvider for clarity
- BuildPeerTLSConfig for future node-to-node mTLS
- switchboard-ca.sh: CA init, issue-node, issue-user (ECDSA P-256)
- 12 new tests (unit + TLS integration)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 18:18:24 +00:00
parent 7915d84c8b
commit 1b095d50be
14 changed files with 1138 additions and 209 deletions

94
server/auth/mtls_proxy.go Normal file
View File

@@ -0,0 +1,94 @@
package auth
import (
"fmt"
"github.com/gin-gonic/gin"
"switchboard-core/store"
)
// MTLSProxyConfig holds configuration for the proxy-terminated mTLS provider.
type MTLSProxyConfig struct {
HeaderDN string // header carrying cert DN (default "X-SSL-Client-DN")
HeaderVerify string // header carrying verify status (default "X-SSL-Client-Verify")
HeaderFingerprint string // header carrying cert fingerprint (default "X-SSL-Client-Fingerprint")
AutoActivate bool // auto-activate new users (default true)
DefaultTeam string // team ID for auto-provisioned users (optional)
}
// MTLSProxyProvider authenticates via client certificate headers injected
// by the TLS-terminating reverse proxy (nginx, Traefik, Istio).
//
// The backend never sees the actual TLS handshake — it trusts headers
// injected by the proxy after cert validation. After reading and parsing
// the DN, the provider resolves an existing user or auto-provisions a
// new one, then returns a Result. The auth handler issues an internal JWT.
//
// Header flow:
//
// Client cert → nginx ssl_verify_client → injects X-SSL-Client-DN,
// X-SSL-Client-Verify, X-SSL-Client-Fingerprint → backend reads headers.
type MTLSProxyProvider struct {
cfg MTLSProxyConfig
}
func NewMTLSProxyProvider(cfg MTLSProxyConfig) *MTLSProxyProvider {
if cfg.HeaderDN == "" {
cfg.HeaderDN = "X-SSL-Client-DN"
}
if cfg.HeaderVerify == "" {
cfg.HeaderVerify = "X-SSL-Client-Verify"
}
if cfg.HeaderFingerprint == "" {
cfg.HeaderFingerprint = "X-SSL-Client-Fingerprint"
}
return &MTLSProxyProvider{cfg: cfg}
}
func (p *MTLSProxyProvider) Mode() Mode { return ModeMTLS }
func (p *MTLSProxyProvider) SupportsRegistration() bool { return false }
func (p *MTLSProxyProvider) Register(_ *gin.Context, _ store.Stores) (*Result, error) {
return nil, ErrNotSupported
}
// Authenticate reads the cert DN and verify status from proxy headers,
// parses the DN fields, and resolves or auto-provisions the user.
//
// Returns ErrInvalidCreds when headers are missing or verify fails.
// Returns ErrInactive when user exists but is deactivated.
func (p *MTLSProxyProvider) Authenticate(c *gin.Context, stores store.Stores) (*Result, error) {
// ── Validate headers ───────────────────────────────────────────
verify := c.GetHeader(p.cfg.HeaderVerify)
if verify == "" {
return nil, fmt.Errorf("%w: missing %s header", ErrInvalidCreds, p.cfg.HeaderVerify)
}
// nginx: "SUCCESS", Traefik: "0" (both mean valid cert)
if verify != "SUCCESS" && verify != "0" {
return nil, fmt.Errorf("%w: cert verify=%s", ErrInvalidCreds, verify)
}
dn := c.GetHeader(p.cfg.HeaderDN)
if dn == "" {
return nil, fmt.Errorf("%w: missing %s header", ErrInvalidCreds, p.cfg.HeaderDN)
}
fields := ParseDN(dn)
cn := fields["CN"]
if cn == "" {
return nil, fmt.Errorf("%w: cert DN has no CN field", ErrInvalidCreds)
}
// Stable external identity: fingerprint if available, else full DN
fingerprint := c.GetHeader(p.cfg.HeaderFingerprint)
if fingerprint == "" {
fingerprint = dn
}
return resolveOrProvision(
c.Request.Context(), stores, cn, fields, fingerprint,
p.cfg.AutoActivate, p.cfg.DefaultTeam,
)
}