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>
109 lines
3.1 KiB
Go
109 lines
3.1 KiB
Go
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
|
|
}
|