Skip to content

Commit 99adb3d

Browse files
sarg3ntclaude
andauthored
fix(passkey): unbreak registration — route mount + localhost gate (#63)
* fix(passkey): mount /api/passkey/register/* at the protected-group root The two register routes were nested inside r.Route("/settings", ...), so their effective paths were /settings/api/passkey/register/{begin,finish}. The frontend (passkey-registration.js and the inline JS in user_pages.templ) calls /api/passkey/register/begin, matching the public /api/passkey/login/* routes — and every call 404'd: POST http://localhost:3000/api/passkey/register/begin → 404 Move the two register endpoints up one level so they peer with logout and the root redirect inside the protected group. They still pick up RequireAuth, RequirePasswordChange, InjectIntegrationStatus, and the 60s timeout from that group's middleware — auth is enforced by the group, not the URL prefix. The PasskeyDelete endpoint stays at /settings/profile/passkey/delete because it's a profile-page action, not a passkey-API call. Result: passkey URLs are symmetric — /api/passkey/login/* (public) and /api/passkey/register/* (authenticated). Closes #44 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(passkey): stop excluding localhost from WebAuthn init The gate at cmd/server/main.go was if cfg.WebAuthnRPID != "" && cfg.WebAuthnRPID != "localhost" …which meant any dev box running `make dev` (BASE_URL=http://localhost:3000) got an uninitialized WebAuthn manager, and every passkey registration attempt 500'd with "WebAuthn not configured." `localhost` is a valid RPID per the WebAuthn spec (Level 2 §4 / §13.4.8) and is whitelisted as a secure origin by every browser specifically so dev installs don't need a TLS cert and a real hostname. The `go-webauthn/webauthn` library accepts it without complaint. Surfaced while verifying the routing fix for #44 — the routing change alone left the same user goal still broken because the handler shortcuts on a nil manager before doing anything useful. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(passkey): address Copilot review on #63 - Warn at startup if WebAuthn RPID resolved to "localhost" *and* BASE_URL was never explicitly set. Without this, an operator who forgot to configure BASE_URL in production gets a working-looking Passkey UI that fails at registration with an opaque origin-mismatch error from the browser. The warning makes the misconfig findable in the journal. - Set `Cache-Control: no-store` (+ `Pragma: no-cache` for old proxies) on the PasskeyRegisterBegin response. The body carries a single-use challenge + session_id bound to the requesting user; an intermediary cache could surface the same challenge to a different user. The /finish handler's per-user session-ID check would reject the cross-user case, but defense-in-depth costs two header lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9db25e9 commit 99adb3d

2 files changed

Lines changed: 27 additions & 7 deletions

File tree

gearbox/cmd/server/main.go

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -398,8 +398,10 @@ func main() {
398398
}
399399
logger.Info("gear system initialized")
400400

401-
// Initialize WebAuthn for passkey support
402-
if cfg.WebAuthnRPID != "" && cfg.WebAuthnRPID != "localhost" {
401+
// Initialize WebAuthn for passkey support. `localhost` is a valid RPID
402+
// per the WebAuthn spec and is whitelisted as a secure origin by every
403+
// browser specifically for dev — don't gate it out.
404+
if cfg.WebAuthnRPID != "" {
403405
webAuthnCfg := &auth.WebAuthnConfig{
404406
RPDisplayName: cfg.WebAuthnRPDisplayName,
405407
RPID: cfg.WebAuthnRPID,
@@ -414,9 +416,20 @@ func main() {
414416
logger.Info("WebAuthn initialized",
415417
"rp_id", cfg.WebAuthnRPID,
416418
"origins", cfg.WebAuthnRPOrigins)
419+
// If RPID is "localhost" but BASE_URL was never explicitly set,
420+
// the operator probably forgot to configure it. Passkey UI will
421+
// render but registration will fail at the authenticator with an
422+
// opaque origin-mismatch error — warn loudly so the misconfig is
423+
// findable in the journal.
424+
if cfg.WebAuthnRPID == "localhost" && os.Getenv("BASE_URL") == "" {
425+
logger.Warn("WebAuthn RPID is 'localhost' because BASE_URL is unset — " +
426+
"passkey UI will appear, but registration from any non-localhost " +
427+
"hostname will fail with an origin-mismatch error. Set BASE_URL " +
428+
"(or WEBAUTHN_RP_ID/WEBAUTHN_RP_ORIGINS) in production.")
429+
}
417430
}
418431
} else {
419-
logger.Info("WebAuthn disabled (requires BASE_URL with non-localhost domain)")
432+
logger.Info("WebAuthn disabled (BASE_URL did not yield a hostname)")
420433
}
421434

422435
// Setup router
@@ -523,6 +536,12 @@ func main() {
523536
// (per-user → system → fallback). See feature/dashboard-gear F1.
524537
r.Get("/", h.RootRedirect)
525538

539+
// Passkey registration (authenticated). Mounted at the same /api/passkey
540+
// prefix as the public login routes above for URL symmetry; auth is
541+
// supplied by this protected group's middleware, not the path.
542+
r.Get("/api/passkey/register/begin", h.PasskeyRegisterBegin)
543+
r.Post("/api/passkey/register/finish", h.PasskeyRegisterFinish)
544+
526545
// First-run onboarding (issue #49). Admin-only. The /welcome page
527546
// self-redirects to / once onboarding is complete (any box or
528547
// system gear enabled), so it's safe to leave reachable.
@@ -544,9 +563,7 @@ func main() {
544563
r.Get("/change-password", h.ChangePasswordPage)
545564
r.Post("/change-password", h.ChangePasswordPost)
546565

547-
// Passkey management
548-
r.Get("/api/passkey/register/begin", h.PasskeyRegisterBegin)
549-
r.Post("/api/passkey/register/finish", h.PasskeyRegisterFinish)
566+
// Passkey deletion (still under /settings as a profile sub-action)
550567
r.Post("/profile/passkey/delete", h.PasskeyDelete)
551568

552569
// User management routes (require admin or approve_users permission)

gearbox/internal/framework/handler/passkeys.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,11 @@ func (h *Handler) PasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
8989
return
9090
}
9191

92-
// Return options to client
92+
// Return options to client. The challenge is single-use and bound to
93+
// the session ID we just stored — never let an intermediary cache it.
9394
w.Header().Set("Content-Type", "application/json")
95+
w.Header().Set("Cache-Control", "no-store")
96+
w.Header().Set("Pragma", "no-cache")
9497
json.NewEncoder(w).Encode(PasskeyRegisterBeginResponse{ //#nosec G104
9598
Options: options,
9699
SessionID: sessionID,

0 commit comments

Comments
 (0)