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 }