// 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" "sync" "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 // authFunc validates an authenticated session for registration // (P04, T9, C-45). When non-nil, BeginRegistration and // FinishRegistration require a valid session (cookie or bearer // token) before proceeding; a nil/error result yields 401. When // nil (fail-closed for new deployments), registration is rejected // with 401 — the operator MUST wire an authFunc before enabling // registration. This fixes C-45's unauthenticated-registration // hole: previously anyone could register a credential for any // username. authFunc func(r *http.Request) (authenticated bool, existingUser string, err error) } // 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) { return NewConnectorWithAuth(store, rpID, rpOrigin, nil) } // NewConnectorWithAuth builds a Connector with an explicit auth // function for registration (P04, T9). The authFunc returns whether // the request carries a valid authenticated session and, optionally, // the existing user identity (so registration can be scoped to the // authenticated user). When authFunc is nil, registration is fail- // closed (401). func NewConnectorWithAuth(store *Store, rpID, rpOrigin string, authFunc func(r *http.Request) (bool, string, error)) (*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, authFunc: authFunc, }, 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). In production // this would be a Redis/shared cache; for the bundled single-lead // Dex, an in-memory map with TTL is sufficient. // // REQ-156 / P07 T10: the session maps are accessed from HTTP handler // goroutines (one goroutine per request) and were previously plain // maps with no synchronization. Concurrent BeginRegistration calls // for the same username would race on map writes (detected by go // test -race in T11). A sync.Mutex now guards all access. type sessionStore struct { mu sync.Mutex sessions map[string]*RegistrationSession } // regSessions is the global in-flight registration session store. var regSessions = &sessionStore{sessions: make(map[string]*RegistrationSession)} // loginSessionStore holds in-flight login sessions (T10). Same // mutex pattern as sessionStore. type loginSessionStore struct { mu sync.Mutex sessions map[string]*LoginSession } // loginSessions is the global in-flight login session store. var loginSessions = &loginSessionStore{sessions: make(map[string]*LoginSession)} // sessionTTL is the max time a registration/login session is valid. const sessionTTL = 5 * time.Minute // cleanSessions removes expired registration + login sessions. // Called under each store's lock by the Begin* handlers. func cleanSessions() { now := time.Now() regSessions.mu.Lock() for id, sess := range regSessions.sessions { if now.Sub(sess.CreatedAt) > sessionTTL { delete(regSessions.sessions, id) } } regSessions.mu.Unlock() loginSessions.mu.Lock() for id, sess := range loginSessions.sessions { if now.Sub(sess.CreatedAt) > sessionTTL { delete(loginSessions.sessions, id) } } loginSessions.mu.Unlock() } // requireAuth checks the request for an authenticated session. When // c.authFunc is nil, registration is fail-closed (401). When the // authFunc returns false or an error, the request is rejected with // 401 Unauthorized. Returns true when the request is authenticated. // // The authFunc may also return the existing user identity so // registration can be scoped (a user can only register credentials // for their own account); the existing-user scoping is enforced by // the caller via the username query param match (a future phase will // wire the authenticated user as the registration target instead of // accepting a free-form username). func (c *Connector) requireAuth(w http.ResponseWriter, r *http.Request) bool { if c.authFunc == nil { http.Error(w, "registration requires authentication (no auth function configured)", http.StatusUnauthorized) return false } ok, _, err := c.authFunc(r) if err != nil || !ok { http.Error(w, "authentication required", http.StatusUnauthorized) return false } return true } // SetAuthFunc sets the registration auth function (P04, T9). Allows // callers to wire the auth check after construction (e.g. when the // session store is initialized later). func (c *Connector) SetAuthFunc(f func(r *http.Request) (bool, string, error)) { c.authFunc = f } // 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) { // P04 (T9, C-45): require an authenticated session before // allowing registration. Without this, anyone could register a // credential for any username. When authFunc is nil, fail-closed. if !c.requireAuth(w, r) { return } 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.mu.Lock() regSessions.sessions[sessionID] = &RegistrationSession{ UserID: username, Challenge: session, CreatedAt: time.Now(), } regSessions.mu.Unlock() 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) { // P04 (T9): require an authenticated session for finish too. if !c.requireAuth(w, r) { return } username := r.URL.Query().Get("username") if username == "" { http.Error(w, "username required", http.StatusBadRequest) return } sessionID := base64.RawURLEncoding.EncodeToString([]byte(username)) regSessions.mu.Lock() session, ok := regSessions.sessions[sessionID] if !ok { regSessions.mu.Unlock() http.Error(w, "no registration session; call /register first", http.StatusBadRequest) return } if time.Since(session.CreatedAt) > sessionTTL { delete(regSessions.sessions, sessionID) regSessions.mu.Unlock() http.Error(w, "session expired", http.StatusBadRequest) return } regSessions.mu.Unlock() 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 } regSessions.mu.Lock() delete(regSessions.sessions, sessionID) regSessions.mu.Unlock() 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 } // 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.mu.Lock() loginSessions.sessions[username] = &LoginSession{ UserID: username, Challenge: session, CreatedAt: time.Now(), } loginSessions.mu.Unlock() 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 } loginSessions.mu.Lock() session, ok := loginSessions.sessions[username] if !ok { loginSessions.mu.Unlock() http.Error(w, "no login session; call /login first", http.StatusBadRequest) return } if time.Since(session.CreatedAt) > sessionTTL { delete(loginSessions.sessions, username) loginSessions.mu.Unlock() http.Error(w, "session expired", http.StatusBadRequest) return } loginSessions.mu.Unlock() 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) loginSessions.mu.Lock() delete(loginSessions.sessions, username) loginSessions.mu.Unlock() // 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