Files
orca/internal/webauthn/connector.go
T
Jon Chery 0358efe95b fix(P07): concurrency safety — SQLite, flock, cache, atomic writes (REQ-156)
- SQLite busy_timeout(5000) + SetMaxOpenConns(1) on all 4 DSNs
- secrets file flock (concurrent set on same ns no longer loses data)
- upgrade lock file (refuse concurrent orca upgrade)
- backup lock file (refuse concurrent backup)
- cache invalidation by writes (read-after-write consistency)
- Executor.Run mutex scope fix (hold only for DB inserts)
- ns create/inherit/set-constraint atomic writeNSMdAtomic
- writeCurrentLead + rotateSSHKeys atomic
- consolidate 3 writeAtomic impls onto security.WriteAtomic
- WebAuthn session stores guarded with sync.Mutex

Tests: concurrent secrets set, upgrade lock rejection, cache
read-after-write, WebAuthn session thread-safety (pass under -race).

---ci---
project: orca
phase: 7
milestone: v0.13
status: complete
requirements:
  covered: [156]
---/ci---
2026-08-10 12:27:05 +00:00

417 lines
14 KiB
Go

// 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=<name>
// 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=<name>
// 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=<name>
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=<name>
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