// Package webauthn: connector.go implements the WebAuthn ceremony // handler for the bundled Dex (REQ-148, D-240, C-38). It serves // registration + login endpoints at /orca/webauthn/{register,login} // behind Traefik (R-017, step-ca cert, HTTPS secure context). // // The connector uses github.com/go-webauthn/webauthn for the // cryptographic ceremony logic. Credential storage is in store.go // (SQLite, 0600, public keys only). package webauthn import ( "context" "encoding/base64" "encoding/json" "fmt" "net/http" "strings" "time" "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" ) // Connector is the WebAuthn ceremony handler. It is mounted behind // Traefik and called by the bundled Dex. type Connector struct { w *webauthn.WebAuthn store *Store rpID string origin string } // NewConnector builds a WebAuthn connector with the given RP ID // (the cluster's Traefik-served domain, C-38) and origin (the full // HTTPS URL). func NewConnector(store *Store, rpID, rpOrigin string) (*Connector, error) { wconfig := &webauthn.Config{ RPDisplayName: "Orca", RPID: rpID, RPOrigins: []string{rpOrigin}, } w, err := webauthn.New(wconfig) if err != nil { return nil, fmt.Errorf("webauthn: new: %w", err) } return &Connector{ w: w, store: store, rpID: rpID, origin: rpOrigin, }, nil } // RegistrationSession holds the in-flight registration challenge. type RegistrationSession struct { UserID string Challenge *webauthn.SessionData CreatedAt time.Time } // sessionStore holds in-flight sessions (registration + login). In // production this would be a Redis/shared cache; for the bundled // single-lead Dex, an in-memory map with TTL is sufficient. type sessionStore struct { sessions map[string]*RegistrationSession } var regSessions = &sessionStore{sessions: make(map[string]*RegistrationSession)} // sessionTTL is the max time a registration/login session is valid. const sessionTTL = 5 * time.Minute // cleanSessions removes expired sessions. func cleanSessions() { now := time.Now() for id, s := range regSessions.sessions { if now.Sub(s.CreatedAt) > sessionTTL { delete(regSessions.sessions, id) } } } // BeginRegistration starts the WebAuthn registration ceremony. // GET /orca/webauthn/register?username= // Returns the creation options (challenge) for the browser. func (c *Connector) BeginRegistration(w http.ResponseWriter, r *http.Request) { username := r.URL.Query().Get("username") if username == "" { http.Error(w, "username required", http.StatusBadRequest) return } userID := []byte(username) existing, _ := c.store.GetCredential(username) var creds []webauthn.Credential if existing != nil { creds = append(creds, webauthn.Credential{ ID: existing.CredentialID, PublicKey: existing.PublicKey, AttestationType: "none", }) } user := &webauthnUser{id: userID, name: username, credentials: creds} options, session, err := c.w.BeginRegistration(user) if err != nil { http.Error(w, fmt.Sprintf("begin registration: %v", err), http.StatusInternalServerError) return } sessionID := base64.RawURLEncoding.EncodeToString(userID) regSessions.sessions[sessionID] = &RegistrationSession{ UserID: username, Challenge: session, CreatedAt: time.Now(), } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(options) } // FinishRegistration completes the WebAuthn registration ceremony. // POST /orca/webauthn/register/finish?username= // Body: the attestation response from the browser. func (c *Connector) FinishRegistration(w http.ResponseWriter, r *http.Request) { username := r.URL.Query().Get("username") if username == "" { http.Error(w, "username required", http.StatusBadRequest) return } sessionID := base64.RawURLEncoding.EncodeToString([]byte(username)) session, ok := regSessions.sessions[sessionID] if !ok { http.Error(w, "no registration session; call /register first", http.StatusBadRequest) return } if time.Since(session.CreatedAt) > sessionTTL { delete(regSessions.sessions, sessionID) http.Error(w, "session expired", http.StatusBadRequest) return } parsed, err := protocol.ParseCredentialCreationResponseBody(r.Body) if err != nil { http.Error(w, fmt.Sprintf("parse attestation: %v", err), http.StatusBadRequest) return } user := &webauthnUser{id: []byte(username), name: username} cred, err := c.w.CreateCredential(user, *session.Challenge, parsed) if err != nil { http.Error(w, fmt.Sprintf("create credential: %v", err), http.StatusInternalServerError) return } storeCred := &Credential{ UserID: username, CredentialID: cred.ID, PublicKey: cred.PublicKey, SignCount: 0, AAGUID: "", CreatedAt: time.Now(), } if err := c.store.PutCredential(storeCred); err != nil { http.Error(w, fmt.Sprintf("store credential: %v", err), http.StatusInternalServerError) return } delete(regSessions.sessions, sessionID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "registered", "user_id": username}) } // LoginSession holds the in-flight login challenge. type LoginSession struct { UserID string Challenge *webauthn.SessionData CreatedAt time.Time } var loginSessions = map[string]*LoginSession{} // BeginLogin starts the WebAuthn login ceremony. // GET /orca/webauthn/login?username= func (c *Connector) BeginLogin(w http.ResponseWriter, r *http.Request) { cleanSessions() username := r.URL.Query().Get("username") if username == "" { http.Error(w, "username required", http.StatusBadRequest) return } existing, _ := c.store.GetCredential(username) if existing == nil { http.Error(w, "user not registered", http.StatusNotFound) return } user := &webauthnUser{ id: []byte(username), name: username, credentials: []webauthn.Credential{{ID: existing.CredentialID, PublicKey: existing.PublicKey}}, } options, session, err := c.w.BeginLogin(user) if err != nil { http.Error(w, fmt.Sprintf("begin login: %v", err), http.StatusInternalServerError) return } loginSessions[username] = &LoginSession{ UserID: username, Challenge: session, CreatedAt: time.Now(), } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(options) } // FinishLogin completes the WebAuthn login ceremony. // POST /orca/webauthn/login/finish?username= func (c *Connector) FinishLogin(w http.ResponseWriter, r *http.Request) { username := r.URL.Query().Get("username") if username == "" { http.Error(w, "username required", http.StatusBadRequest) return } session, ok := loginSessions[username] if !ok { http.Error(w, "no login session; call /login first", http.StatusBadRequest) return } if time.Since(session.CreatedAt) > sessionTTL { delete(loginSessions, username) http.Error(w, "session expired", http.StatusBadRequest) return } existing, _ := c.store.GetCredential(username) if existing == nil { http.Error(w, "user not registered", http.StatusNotFound) return } user := &webauthnUser{ id: []byte(username), name: username, credentials: []webauthn.Credential{{ID: existing.CredentialID, PublicKey: existing.PublicKey}}, } parsed, err := protocol.ParseCredentialRequestResponseBody(r.Body) if err != nil { http.Error(w, fmt.Sprintf("parse assertion: %v", err), http.StatusBadRequest) return } cred, err := c.w.ValidateLogin(user, *session.Challenge, parsed) if err != nil { http.Error(w, fmt.Sprintf("validate login: %v", err), http.StatusUnauthorized) return } _ = c.store.UpdateSignCount(username, cred.Authenticator.SignCount) delete(loginSessions, username) // The OIDC sub is the username (the connector maps credential ID // to sub). Dex uses this to issue the ID token. w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "status": "authenticated", "sub": username, }) } // Routes returns the HTTP handler mux for the WebAuthn connector. // Mount under /orca/webauthn/ behind Traefik. func (c *Connector) Routes() *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("/orca/webauthn/register", c.BeginRegistration) mux.HandleFunc("/orca/webauthn/register/finish", c.FinishRegistration) mux.HandleFunc("/orca/webauthn/login", c.BeginLogin) mux.HandleFunc("/orca/webauthn/login/finish", c.FinishLogin) mux.HandleFunc("/orca/webauthn/healthz", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"status":"ok","rp_id":"` + c.rpID + `"}`)) }) return mux } // Serve starts the WebAuthn HTTP handler on the given address. In // production this runs behind Traefik (which provides TLS); the bind // address is loopback only. func (c *Connector) Serve(ctx context.Context, addr string) error { srv := &http.Server{ Addr: addr, Handler: c.Routes(), ReadHeaderTimeout: 5 * time.Second, } go func() { <-ctx.Done() ctx2, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = srv.Shutdown(ctx2) }() if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { return fmt.Errorf("webauthn: serve: %w", err) } return nil } // webauthnUser implements webauthn.User. type webauthnUser struct { id []byte name string credentials []webauthn.Credential } func (u *webauthnUser) WebAuthnID() []byte { return u.id } func (u *webauthnUser) WebAuthnName() string { return u.name } func (u *webauthnUser) WebAuthnDisplayName() string { return u.name } func (u *webauthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials } func (u *webauthnUser) WebAuthnIcon() string { return "" } // RPID returns the configured relying-party ID. func (c *Connector) RPID() string { return c.rpID } // Ensure strings import is used (for the healthz handler). var _ = strings.Contains