Feat v0.6.7 native mtls (#42)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #42.
This commit is contained in:
@@ -1,180 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"switchboard-core/models"
|
||||
"switchboard-core/store"
|
||||
)
|
||||
|
||||
// MTLSConfig holds mTLS-specific configuration.
|
||||
type MTLSConfig 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)
|
||||
}
|
||||
|
||||
// MTLSProvider 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 MTLSProvider struct {
|
||||
cfg MTLSConfig
|
||||
}
|
||||
|
||||
func NewMTLSProvider(cfg MTLSConfig) *MTLSProvider {
|
||||
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 &MTLSProvider{cfg: cfg}
|
||||
}
|
||||
|
||||
func (p *MTLSProvider) Mode() Mode { return ModeMTLS }
|
||||
|
||||
func (p *MTLSProvider) SupportsRegistration() bool { return false }
|
||||
|
||||
func (p *MTLSProvider) 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 *MTLSProvider) 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
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// ── Look up existing user ──────────────────────────────────────
|
||||
user, err := stores.Users.GetByExternalID(ctx, string(ModeMTLS), fingerprint)
|
||||
if err == nil && user != nil {
|
||||
if !user.IsActive {
|
||||
return nil, ErrInactive
|
||||
}
|
||||
log.Printf("[auth/mtls] existing user %s (%s)", user.Username, cn)
|
||||
return &Result{User: user, IsNewUser: false, VaultHint: ""}, nil
|
||||
}
|
||||
|
||||
// ── Auto-provision ─────────────────────────────────────────────
|
||||
if !p.cfg.AutoActivate {
|
||||
return nil, fmt.Errorf("%w: auto-provision disabled", ErrInvalidCreds)
|
||||
}
|
||||
|
||||
return p.autoProvision(ctx, fields, fingerprint, stores)
|
||||
}
|
||||
|
||||
func (p *MTLSProvider) autoProvision(
|
||||
ctx context.Context,
|
||||
dn map[string]string,
|
||||
fingerprint string,
|
||||
stores store.Stores,
|
||||
) (*Result, error) {
|
||||
cn := dn["CN"]
|
||||
|
||||
email := dn["emailAddress"]
|
||||
if email == "" {
|
||||
email = models.HandleFromName(cn) + "@mtls.local"
|
||||
}
|
||||
|
||||
handle := UniqueHandle(ctx, stores.Users, models.HandleFromName(cn))
|
||||
|
||||
user := &models.User{
|
||||
Username: handle,
|
||||
Email: email,
|
||||
DisplayName: cn,
|
||||
IsActive: true,
|
||||
AuthSource: string(ModeMTLS),
|
||||
ExternalID: &fingerprint,
|
||||
Handle: handle,
|
||||
}
|
||||
|
||||
if err := stores.Users.Create(ctx, user); err != nil {
|
||||
return nil, fmt.Errorf("auto-provision failed: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[auth/mtls] auto-provisioned user %s from CN=%s", user.ID, cn)
|
||||
EnsureEveryoneGroup(ctx, stores, user.ID)
|
||||
|
||||
// Auto-add to default team if configured
|
||||
if p.cfg.DefaultTeam != "" {
|
||||
if err := stores.Teams.AddMember(ctx, p.cfg.DefaultTeam, user.ID, "member"); err != nil {
|
||||
log.Printf("[auth/mtls] warn: could not add %s to default team: %v", user.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return &Result{User: user, IsNewUser: true, VaultHint: ""}, nil
|
||||
}
|
||||
|
||||
// ParseDN parses an RFC 2253 / RFC 4514 distinguished name into key-value pairs.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// "CN=Jeff Smith,O=Acme Corp,OU=Engineering"
|
||||
// "CN=Jane Doe,emailAddress=jane@acme.com,O=Acme Corp"
|
||||
//
|
||||
// Handles simple comma-separated key=value pairs. Does NOT handle
|
||||
// escaped commas in values (\,) or multi-valued RDNs (+). Sufficient
|
||||
// for typical X.509 client cert DNs.
|
||||
func ParseDN(dn string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
parts := strings.Split(dn, ",")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
idx := strings.Index(part, "=")
|
||||
if idx < 1 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(part[:idx])
|
||||
val := strings.TrimSpace(part[idx+1:])
|
||||
result[key] = val
|
||||
}
|
||||
return result
|
||||
}
|
||||
108
server/auth/mtls_helpers.go
Normal file
108
server/auth/mtls_helpers.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"switchboard-core/models"
|
||||
"switchboard-core/store"
|
||||
)
|
||||
|
||||
// ParseDN parses an RFC 2253 / RFC 4514 distinguished name into key-value pairs.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// "CN=Jeff Smith,O=Acme Corp,OU=Engineering"
|
||||
// "CN=Jane Doe,emailAddress=jane@acme.com,O=Acme Corp"
|
||||
//
|
||||
// Handles simple comma-separated key=value pairs. Does NOT handle
|
||||
// escaped commas in values (\,) or multi-valued RDNs (+). Sufficient
|
||||
// for typical X.509 client cert DNs.
|
||||
func ParseDN(dn string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
parts := strings.Split(dn, ",")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
idx := strings.Index(part, "=")
|
||||
if idx < 1 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(part[:idx])
|
||||
val := strings.TrimSpace(part[idx+1:])
|
||||
result[key] = val
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// FingerprintCert returns the hex-encoded SHA-256 hash of a certificate's
|
||||
// raw DER encoding. This is used as the stable external_id for mTLS users.
|
||||
func FingerprintCert(cert *x509.Certificate) string {
|
||||
h := sha256.Sum256(cert.Raw)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// resolveOrProvision looks up an existing user by auth_source=mtls and external_id,
|
||||
// or auto-provisions a new user from the certificate's CN and fingerprint.
|
||||
//
|
||||
// Shared by both MTLSProxyProvider and MTLSNativeProvider.
|
||||
func resolveOrProvision(
|
||||
ctx context.Context,
|
||||
stores store.Stores,
|
||||
cn string,
|
||||
dnFields map[string]string,
|
||||
fingerprint string,
|
||||
autoActivate bool,
|
||||
defaultTeam string,
|
||||
) (*Result, error) {
|
||||
// ── Look up existing user ──────────────────────────────────────
|
||||
user, err := stores.Users.GetByExternalID(ctx, string(ModeMTLS), fingerprint)
|
||||
if err == nil && user != nil {
|
||||
if !user.IsActive {
|
||||
return nil, ErrInactive
|
||||
}
|
||||
log.Printf("[auth/mtls] existing user %s (%s)", user.Username, cn)
|
||||
return &Result{User: user, IsNewUser: false, VaultHint: ""}, nil
|
||||
}
|
||||
|
||||
// ── Auto-provision ─────────────────────────────────────────────
|
||||
if !autoActivate {
|
||||
return nil, fmt.Errorf("%w: auto-provision disabled", ErrInvalidCreds)
|
||||
}
|
||||
|
||||
email := dnFields["emailAddress"]
|
||||
if email == "" {
|
||||
email = models.HandleFromName(cn) + "@mtls.local"
|
||||
}
|
||||
|
||||
handle := UniqueHandle(ctx, stores.Users, models.HandleFromName(cn))
|
||||
|
||||
user = &models.User{
|
||||
Username: handle,
|
||||
Email: email,
|
||||
DisplayName: cn,
|
||||
IsActive: true,
|
||||
AuthSource: string(ModeMTLS),
|
||||
ExternalID: &fingerprint,
|
||||
Handle: handle,
|
||||
}
|
||||
|
||||
if err := stores.Users.Create(ctx, user); err != nil {
|
||||
return nil, fmt.Errorf("auto-provision failed: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[auth/mtls] auto-provisioned user %s from CN=%s", user.ID, cn)
|
||||
EnsureEveryoneGroup(ctx, stores, user.ID)
|
||||
|
||||
if defaultTeam != "" {
|
||||
if err := stores.Teams.AddMember(ctx, defaultTeam, user.ID, "member"); err != nil {
|
||||
log.Printf("[auth/mtls] warn: could not add %s to default team: %v", user.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return &Result{User: user, IsNewUser: true, VaultHint: ""}, nil
|
||||
}
|
||||
72
server/auth/mtls_native.go
Normal file
72
server/auth/mtls_native.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"switchboard-core/store"
|
||||
)
|
||||
|
||||
// ErrNoCert is returned when no client certificate is presented on a
|
||||
// connection that requires mTLS authentication.
|
||||
var ErrNoCert = errors.New("no client certificate presented")
|
||||
|
||||
// MTLSNativeConfig holds configuration for the native (non-proxy) mTLS provider.
|
||||
type MTLSNativeConfig struct {
|
||||
AutoActivate bool // auto-activate new users (default true)
|
||||
DefaultTeam string // team ID for auto-provisioned users (optional)
|
||||
}
|
||||
|
||||
// MTLSNativeProvider authenticates by reading the peer certificate
|
||||
// directly from the TLS connection state. Unlike MTLSProxyProvider,
|
||||
// it does not trust headers — identity is cryptographically verified
|
||||
// by the Go TLS stack before the HTTP layer runs.
|
||||
//
|
||||
// Requires TLS_MODE=mtls so the binary terminates TLS itself.
|
||||
type MTLSNativeProvider struct {
|
||||
cfg MTLSNativeConfig
|
||||
}
|
||||
|
||||
// NewMTLSNativeProvider creates a native mTLS auth provider.
|
||||
func NewMTLSNativeProvider(cfg MTLSNativeConfig) *MTLSNativeProvider {
|
||||
return &MTLSNativeProvider{cfg: cfg}
|
||||
}
|
||||
|
||||
func (p *MTLSNativeProvider) Mode() Mode { return ModeMTLS }
|
||||
|
||||
func (p *MTLSNativeProvider) SupportsRegistration() bool { return false }
|
||||
|
||||
func (p *MTLSNativeProvider) Register(_ *gin.Context, _ store.Stores) (*Result, error) {
|
||||
return nil, ErrNotSupported
|
||||
}
|
||||
|
||||
// Authenticate reads the verified peer certificate from the TLS connection
|
||||
// state. The CN becomes the username, and sha256(cert.Raw) is the stable
|
||||
// external_id. Returns ErrNoCert when no TLS or no peer certificates.
|
||||
func (p *MTLSNativeProvider) Authenticate(c *gin.Context, stores store.Stores) (*Result, error) {
|
||||
if c.Request.TLS == nil || len(c.Request.TLS.PeerCertificates) == 0 {
|
||||
return nil, fmt.Errorf("%w", ErrNoCert)
|
||||
}
|
||||
|
||||
peer := c.Request.TLS.PeerCertificates[0]
|
||||
|
||||
cn := peer.Subject.CommonName
|
||||
if cn == "" {
|
||||
return nil, fmt.Errorf("%w: certificate has no CommonName", ErrInvalidCreds)
|
||||
}
|
||||
|
||||
// Build DN fields from the certificate subject for resolveOrProvision
|
||||
dnFields := map[string]string{"CN": cn}
|
||||
if len(peer.EmailAddresses) > 0 {
|
||||
dnFields["emailAddress"] = peer.EmailAddresses[0]
|
||||
}
|
||||
|
||||
fingerprint := FingerprintCert(peer)
|
||||
|
||||
return resolveOrProvision(
|
||||
c.Request.Context(), stores, cn, dnFields, fingerprint,
|
||||
p.cfg.AutoActivate, p.cfg.DefaultTeam,
|
||||
)
|
||||
}
|
||||
410
server/auth/mtls_native_test.go
Normal file
410
server/auth/mtls_native_test.go
Normal file
@@ -0,0 +1,410 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"switchboard-core/store"
|
||||
)
|
||||
|
||||
// ── Test CA helpers ──────────────────────────────────────────────────
|
||||
|
||||
type testCA struct {
|
||||
Cert *x509.Certificate
|
||||
Key *ecdsa.PrivateKey
|
||||
CertPEM []byte
|
||||
Pool *x509.CertPool
|
||||
}
|
||||
|
||||
func newTestCA(t *testing.T) *testCA {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate CA key: %v", err)
|
||||
}
|
||||
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "Test CA"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create CA cert: %v", err)
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
t.Fatalf("parse CA cert: %v", err)
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(cert)
|
||||
|
||||
return &testCA{Cert: cert, Key: key, CertPEM: certPEM, Pool: pool}
|
||||
}
|
||||
|
||||
type testCert struct {
|
||||
Cert *x509.Certificate
|
||||
Key *ecdsa.PrivateKey
|
||||
TLSCert tls.Certificate
|
||||
}
|
||||
|
||||
func (ca *testCA) issueClient(t *testing.T, cn string, email string) *testCert {
|
||||
t.Helper()
|
||||
return ca.issueCert(t, cn, email, []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, time.Now().Add(time.Hour))
|
||||
}
|
||||
|
||||
func (ca *testCA) issueExpiredClient(t *testing.T, cn string) *testCert {
|
||||
t.Helper()
|
||||
return ca.issueCert(t, cn, "", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, time.Now().Add(-time.Second))
|
||||
}
|
||||
|
||||
func (ca *testCA) issueNode(t *testing.T, cn string) *testCert {
|
||||
t.Helper()
|
||||
return ca.issueCert(t, cn, "", []x509.ExtKeyUsage{
|
||||
x509.ExtKeyUsageServerAuth,
|
||||
x509.ExtKeyUsageClientAuth,
|
||||
}, time.Now().Add(time.Hour))
|
||||
}
|
||||
|
||||
func (ca *testCA) issueCert(t *testing.T, cn, email string, eku []x509.ExtKeyUsage, notAfter time.Time) *testCert {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: notAfter,
|
||||
ExtKeyUsage: eku,
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
|
||||
DNSNames: []string{"localhost"},
|
||||
}
|
||||
if email != "" {
|
||||
tmpl.EmailAddresses = []string{email}
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, ca.Cert, &key.PublicKey, ca.Key)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
t.Fatalf("parse cert: %v", err)
|
||||
}
|
||||
|
||||
tlsCert := tls.Certificate{
|
||||
Certificate: [][]byte{certDER},
|
||||
PrivateKey: key,
|
||||
}
|
||||
|
||||
return &testCert{Cert: cert, Key: key, TLSCert: tlsCert}
|
||||
}
|
||||
|
||||
// ── FingerprintCert tests ───────────────────────────────────────────
|
||||
|
||||
func TestFingerprintCert(t *testing.T) {
|
||||
ca := newTestCA(t)
|
||||
c1 := ca.issueClient(t, "alice", "")
|
||||
c2 := ca.issueClient(t, "alice", "") // same CN, different cert
|
||||
|
||||
fp1 := FingerprintCert(c1.Cert)
|
||||
fp2 := FingerprintCert(c2.Cert)
|
||||
|
||||
if fp1 == "" {
|
||||
t.Error("fingerprint should not be empty")
|
||||
}
|
||||
if len(fp1) != 64 { // sha256 hex = 64 chars
|
||||
t.Errorf("fingerprint length = %d, want 64", len(fp1))
|
||||
}
|
||||
if fp1 == fp2 {
|
||||
t.Error("different certs should have different fingerprints")
|
||||
}
|
||||
|
||||
// Same cert → same fingerprint (deterministic)
|
||||
if FingerprintCert(c1.Cert) != fp1 {
|
||||
t.Error("fingerprint should be deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
// ── MTLSNativeProvider unit tests ───────────────────────────────────
|
||||
|
||||
func TestMTLSNativeProvider_Mode(t *testing.T) {
|
||||
p := NewMTLSNativeProvider(MTLSNativeConfig{})
|
||||
if p.Mode() != ModeMTLS {
|
||||
t.Errorf("Mode() = %q, want %q", p.Mode(), ModeMTLS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMTLSNativeProvider_NoRegistration(t *testing.T) {
|
||||
p := NewMTLSNativeProvider(MTLSNativeConfig{})
|
||||
if p.SupportsRegistration() {
|
||||
t.Error("native mTLS should not support registration")
|
||||
}
|
||||
_, err := p.Register(nil, store.Stores{})
|
||||
if err != ErrNotSupported {
|
||||
t.Errorf("Register() = %v, want ErrNotSupported", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMTLSNativeProvider_NilTLS(t *testing.T) {
|
||||
p := NewMTLSNativeProvider(MTLSNativeConfig{})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/", nil)
|
||||
c.Request.TLS = nil
|
||||
|
||||
_, err := p.Authenticate(c, store.Stores{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil TLS")
|
||||
}
|
||||
if !errors.Is(err, ErrNoCert) {
|
||||
t.Errorf("expected ErrNoCert, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMTLSNativeProvider_EmptyPeerCerts(t *testing.T) {
|
||||
p := NewMTLSNativeProvider(MTLSNativeConfig{})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/", nil)
|
||||
c.Request.TLS = &tls.ConnectionState{
|
||||
PeerCertificates: []*x509.Certificate{},
|
||||
}
|
||||
|
||||
_, err := p.Authenticate(c, store.Stores{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty peer certs")
|
||||
}
|
||||
if !errors.Is(err, ErrNoCert) {
|
||||
t.Errorf("expected ErrNoCert, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMTLSNativeProvider_NoCN(t *testing.T) {
|
||||
p := NewMTLSNativeProvider(MTLSNativeConfig{AutoActivate: true})
|
||||
ca := newTestCA(t)
|
||||
cert := ca.issueCert(t, "", "", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, time.Now().Add(time.Hour))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/", nil)
|
||||
c.Request.TLS = &tls.ConnectionState{
|
||||
PeerCertificates: []*x509.Certificate{cert.Cert},
|
||||
}
|
||||
|
||||
_, err := p.Authenticate(c, store.Stores{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for cert with no CN")
|
||||
}
|
||||
if !errors.Is(err, ErrInvalidCreds) {
|
||||
t.Errorf("expected ErrInvalidCreds, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMTLSNativeProvider_ExtractsEmail(t *testing.T) {
|
||||
ca := newTestCA(t)
|
||||
cert := ca.issueClient(t, "alice", "alice@example.com")
|
||||
|
||||
if len(cert.Cert.EmailAddresses) == 0 || cert.Cert.EmailAddresses[0] != "alice@example.com" {
|
||||
t.Fatalf("cert should have email alice@example.com, got %v", cert.Cert.EmailAddresses)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Integration tests (real TLS listener) ───────────────────────────
|
||||
|
||||
func TestTLS_NoClientCert_Rejected(t *testing.T) {
|
||||
ca := newTestCA(t)
|
||||
serverCert := ca.issueNode(t, "server")
|
||||
|
||||
srv := newMTLSTestServer(t, ca, serverCert)
|
||||
defer srv.Close()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: ca.Pool,
|
||||
MinVersion: tls.VersionTLS13,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := client.Get(srv.URL + "/test")
|
||||
if err == nil {
|
||||
t.Fatal("expected TLS handshake error when no client cert is presented")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLS_WrongCA_Rejected(t *testing.T) {
|
||||
ca := newTestCA(t)
|
||||
wrongCA := newTestCA(t)
|
||||
serverCert := ca.issueNode(t, "server")
|
||||
clientCert := wrongCA.issueClient(t, "intruder", "")
|
||||
|
||||
srv := newMTLSTestServer(t, ca, serverCert)
|
||||
defer srv.Close()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: ca.Pool,
|
||||
Certificates: []tls.Certificate{clientCert.TLSCert},
|
||||
MinVersion: tls.VersionTLS13,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := client.Get(srv.URL + "/test")
|
||||
if err == nil {
|
||||
t.Fatal("expected TLS handshake error when client cert is from wrong CA")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLS_ValidClientCert_Accepted(t *testing.T) {
|
||||
ca := newTestCA(t)
|
||||
serverCert := ca.issueNode(t, "server")
|
||||
clientCert := ca.issueClient(t, "alice", "")
|
||||
|
||||
srv := newMTLSTestServer(t, ca, serverCert)
|
||||
defer srv.Close()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: ca.Pool,
|
||||
Certificates: []tls.Certificate{clientCert.TLSCert},
|
||||
MinVersion: tls.VersionTLS13,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Get(srv.URL + "/test")
|
||||
if err != nil {
|
||||
t.Fatalf("expected successful connection, got: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLS_ExpiredCert_Rejected(t *testing.T) {
|
||||
ca := newTestCA(t)
|
||||
serverCert := ca.issueNode(t, "server")
|
||||
clientCert := ca.issueExpiredClient(t, "expired-user")
|
||||
|
||||
srv := newMTLSTestServer(t, ca, serverCert)
|
||||
defer srv.Close()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: ca.Pool,
|
||||
Certificates: []tls.Certificate{clientCert.TLSCert},
|
||||
MinVersion: tls.VersionTLS13,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := client.Get(srv.URL + "/test")
|
||||
if err == nil {
|
||||
t.Fatal("expected TLS handshake error for expired client cert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLS_PeerCertificateVisible(t *testing.T) {
|
||||
ca := newTestCA(t)
|
||||
serverCert := ca.issueNode(t, "server")
|
||||
clientCert := ca.issueClient(t, "bob", "bob@example.com")
|
||||
|
||||
var seenCN string
|
||||
var seenEmails []string
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 {
|
||||
peer := r.TLS.PeerCertificates[0]
|
||||
seenCN = peer.Subject.CommonName
|
||||
seenEmails = peer.EmailAddresses
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
})
|
||||
|
||||
srv := newMTLSTestServerWithHandler(t, ca, serverCert, handler)
|
||||
defer srv.Close()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: ca.Pool,
|
||||
Certificates: []tls.Certificate{clientCert.TLSCert},
|
||||
MinVersion: tls.VersionTLS13,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Get(srv.URL + "/test")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if seenCN != "bob" {
|
||||
t.Errorf("CN = %q, want %q", seenCN, "bob")
|
||||
}
|
||||
if len(seenEmails) == 0 || seenEmails[0] != "bob@example.com" {
|
||||
t.Errorf("emails = %v, want [bob@example.com]", seenEmails)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test helpers ────────────────────────────────────────────────────
|
||||
|
||||
func newMTLSTestServer(t *testing.T, ca *testCA, serverCert *testCert) *httptest.Server {
|
||||
t.Helper()
|
||||
return newMTLSTestServerWithHandler(t, ca, serverCert, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
fmt.Fprint(w, "ok")
|
||||
}))
|
||||
}
|
||||
|
||||
func newMTLSTestServerWithHandler(t *testing.T, ca *testCA, serverCert *testCert, handler http.Handler) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewUnstartedServer(handler)
|
||||
srv.TLS = &tls.Config{
|
||||
Certificates: []tls.Certificate{serverCert.TLSCert},
|
||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
ClientCAs: ca.Pool,
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}
|
||||
srv.StartTLS()
|
||||
return srv
|
||||
}
|
||||
94
server/auth/mtls_proxy.go
Normal file
94
server/auth/mtls_proxy.go
Normal 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,
|
||||
)
|
||||
}
|
||||
@@ -85,7 +85,7 @@ func TestParseDN(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMTLSConfig_Defaults(t *testing.T) {
|
||||
p := NewMTLSProvider(MTLSConfig{})
|
||||
p := NewMTLSProxyProvider(MTLSProxyConfig{})
|
||||
|
||||
if p.cfg.HeaderDN != "X-SSL-Client-DN" {
|
||||
t.Errorf("HeaderDN = %q, want X-SSL-Client-DN", p.cfg.HeaderDN)
|
||||
@@ -99,7 +99,7 @@ func TestMTLSConfig_Defaults(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMTLSConfig_Custom(t *testing.T) {
|
||||
p := NewMTLSProvider(MTLSConfig{
|
||||
p := NewMTLSProxyProvider(MTLSProxyConfig{
|
||||
HeaderDN: "X-Client-Cert-DN",
|
||||
HeaderVerify: "X-Client-Cert-Verify",
|
||||
})
|
||||
@@ -110,14 +110,14 @@ func TestMTLSConfig_Custom(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMTLSProvider_Mode(t *testing.T) {
|
||||
p := NewMTLSProvider(MTLSConfig{})
|
||||
p := NewMTLSProxyProvider(MTLSProxyConfig{})
|
||||
if p.Mode() != ModeMTLS {
|
||||
t.Errorf("Mode() = %q, want %q", p.Mode(), ModeMTLS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMTLSProvider_NoRegistration(t *testing.T) {
|
||||
p := NewMTLSProvider(MTLSConfig{})
|
||||
p := NewMTLSProxyProvider(MTLSProxyConfig{})
|
||||
if p.SupportsRegistration() {
|
||||
t.Error("mTLS should not support registration")
|
||||
}
|
||||
Reference in New Issue
Block a user