// authapi.go — the WebAuthn ceremony endpoints (unauthenticated: they ARE the // gate) + logout + whoami. register/* is authorised by the one-time enrol token // from the CLI; login/* is usernameless (discoverable passkey). A successful // login mints the server-side session (session.go). package main import ( "fmt" "log" "net/http" "sync" "time" "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" ) // ceremony holds the in-flight WebAuthn SessionData between begin and finish, // plus the enrol claims for a registration. Short-lived, in-memory. type ceremony struct { sess *webauthn.SessionData name string jti string exp int64 expires time.Time } type ceremonyStore struct { mu sync.Mutex m map[string]ceremony } func newCeremonyStore() *ceremonyStore { return &ceremonyStore{m: map[string]ceremony{}} } func (c *ceremonyStore) put(sess *webauthn.SessionData, name, jti string, exp int64) string { id := randToken() c.mu.Lock() now := time.Now() for k, v := range c.m { if now.After(v.expires) { delete(c.m, k) } } c.m[id] = ceremony{sess: sess, name: name, jti: jti, exp: exp, expires: now.Add(5 * time.Minute)} c.mu.Unlock() return id } func (c *ceremonyStore) take(id string) (ceremony, bool) { c.mu.Lock() defer c.mu.Unlock() cer, ok := c.m[id] if ok { delete(c.m, id) } if ok && time.Now().After(cer.expires) { return ceremony{}, false } return cer, ok } func (s *server) authRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") _, _ = w.Write([]byte("ok\n")) }) if s.wa == nil { return // insecure/dev mode has no passkey ceremonies } mux.HandleFunc("POST /auth/register/begin", s.handleRegisterBegin) mux.HandleFunc("POST /auth/register/finish", s.handleRegisterFinish) mux.HandleFunc("POST /auth/login/begin", s.handleLoginBegin) mux.HandleFunc("POST /auth/login/finish", s.handleLoginFinish) mux.HandleFunc("POST /auth/logout", s.handleLogout) } // register/begin — authorised by the one-time enrol token (?token=). func (s *server) handleRegisterBegin(w http.ResponseWriter, r *http.Request) { claims, err := verifyEnrollToken(s.enrollKey, r.URL.Query().Get("token")) if err != nil { writeErr(w, http.StatusUnauthorized, "invalid enrolment token: "+err.Error()) return } if s.creds.tokenUsed(claims.JTI) { writeErr(w, http.StatusConflict, "enrolment token already used") return } user := s.creds.getOrCreate(claims.Name) sel := protocol.AuthenticatorSelection{ ResidentKey: protocol.ResidentKeyRequirementRequired, RequireResidentKey: protocol.ResidentKeyRequired(), UserVerification: protocol.VerificationRequired, } options, sessionData, err := s.wa.BeginRegistration(user, webauthn.WithAuthenticatorSelection(sel)) if err != nil { writeErr(w, http.StatusInternalServerError, "begin registration: "+err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"cid": s.ceremonies.put(sessionData, claims.Name, claims.JTI, claims.Exp), "options": options}) } func (s *server) handleRegisterFinish(w http.ResponseWriter, r *http.Request) { cer, ok := s.ceremonies.take(r.URL.Query().Get("cid")) if !ok || cer.sess == nil { writeErr(w, http.StatusBadRequest, "unknown or expired ceremony") return } if s.creds.tokenUsed(cer.jti) { writeErr(w, http.StatusConflict, "enrolment token already used") return } user := s.creds.getOrCreate(cer.name) cred, err := s.wa.FinishRegistration(user, *cer.sess, r) if err != nil { writeErr(w, http.StatusBadRequest, "registration failed: "+err.Error()) return } if err := s.creds.addCredential(cer.name, *cred); err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } s.creds.markTokenUsed(cer.jti, cer.exp) log.Printf("audit ADMIN-ENROL name=%q (passkey registered)", cer.name) writeJSON(w, http.StatusOK, map[string]any{"admin": cer.name, "registered": true}) } // login/begin — usernameless discoverable login. func (s *server) handleLoginBegin(w http.ResponseWriter, r *http.Request) { options, sessionData, err := s.wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired)) if err != nil { writeErr(w, http.StatusInternalServerError, "begin login: "+err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"cid": s.ceremonies.put(sessionData, "", "", 0), "options": options}) } func (s *server) handleLoginFinish(w http.ResponseWriter, r *http.Request) { cer, ok := s.ceremonies.take(r.URL.Query().Get("cid")) if !ok || cer.sess == nil { writeErr(w, http.StatusBadRequest, "unknown or expired ceremony") return } var matched *adminUser handler := func(rawID, userHandle []byte) (webauthn.User, error) { u, ok := s.creds.byHandle(userHandle) if !ok { return nil, fmt.Errorf("unknown passkey") } matched = u return u, nil } cred, err := s.wa.FinishDiscoverableLogin(handler, *cer.sess, r) if err != nil || matched == nil { writeErr(w, http.StatusUnauthorized, "login failed") return } s.creds.updateCredential(matched.IDb, *cred) // persist the signature counter id, csrf := s.sessions.create(matched.Name) setSessionCookie(w, id, s.cfg.sessionTTL) log.Printf("audit ADMIN-LOGIN name=%q", matched.Name) writeJSON(w, http.StatusOK, map[string]any{"admin": matched.Name, "csrf": csrf}) } func (s *server) handleLogout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(sessionCookie); err == nil { s.sessions.delete(c.Value) } clearSessionCookie(w) w.WriteHeader(http.StatusNoContent) } // handleWhoami is session-gated (registered under /api): lets the SPA resume an // existing session on reload and recover the CSRF token. func (s *server) handleWhoami(w http.ResponseWriter, r *http.Request) { sess, _ := sessionFrom(r) writeJSON(w, http.StatusOK, map[string]any{"admin": adminName(r), "csrf": sess.csrf}) }