From b6d2ded141e69d88177fcd4cf4dd982a5a7b9bc9 Mon Sep 17 00:00:00 2001 From: Tim Bendt Date: Tue, 16 Jun 2026 17:05:15 -0400 Subject: [PATCH] passkey existing --- apps/server/internal/auth/service.go | 51 +++++++++++++++++++ apps/server/internal/auth/service_test.go | 15 ++++++ .../internal/httpserver/api_handlers_test.go | 48 +++++++++++++++++ .../internal/httpserver/auth_handlers.go | 36 +++++++++++++ apps/server/internal/httpserver/middleware.go | 2 +- apps/server/internal/httpserver/server.go | 2 + .../server/internal/httpserver/static/auth.js | 20 ++++++++ apps/server/internal/httpserver/static/sw.js | 2 +- .../httpserver/templates/account.gohtml | 14 +++++ 9 files changed, 188 insertions(+), 2 deletions(-) diff --git a/apps/server/internal/auth/service.go b/apps/server/internal/auth/service.go index e408d5b..7b51f43 100644 --- a/apps/server/internal/auth/service.go +++ b/apps/server/internal/auth/service.go @@ -190,6 +190,57 @@ func (s *Service) FinishPasskeyRegistration(ctx context.Context, challengeID str return s.repo.GetUserByID(ctx, user.ID) } +func (s *Service) BeginCurrentUserPasskeyRegistration(ctx context.Context, principal Principal) (any, string, error) { + if principal.UserID == "" { + return nil, "", fmt.Errorf("authentication required") + } + user, err := s.repo.GetUserByID(ctx, principal.UserID) + if err != nil { + return nil, "", err + } + if user.Disabled { + return nil, "", fmt.Errorf("account disabled") + } + creation, session, err := s.webauthn.BeginRegistration(user) + if err != nil { + return nil, "", err + } + challengeID, err := s.repo.SaveChallenge(ctx, user.ID, "webauthn_registration", *session, challengeTTL) + if err != nil { + return nil, "", err + } + return creation, challengeID, nil +} + +func (s *Service) FinishCurrentUserPasskeyRegistration(ctx context.Context, principal Principal, challengeID string, r *http.Request) (User, error) { + if principal.UserID == "" { + return User{}, fmt.Errorf("authentication required") + } + session, userID, err := s.repo.ConsumeChallenge(ctx, challengeID, "webauthn_registration") + if err != nil { + return User{}, err + } + if userID != principal.UserID { + return User{}, fmt.Errorf("challenge does not belong to the current user") + } + user, err := s.repo.GetUserByID(ctx, userID) + if err != nil { + return User{}, err + } + if user.Disabled { + return User{}, fmt.Errorf("account disabled") + } + credential, err := s.webauthn.FinishRegistration(user, session, r) + if err != nil { + return User{}, err + } + if err := s.repo.SaveCredential(ctx, user.ID, *credential); err != nil { + return User{}, err + } + s.repo.Audit(ctx, user.ID, "auth.passkey.add", "user", user.ID, clientIP(r), r.UserAgent(), nil) + return s.repo.GetUserByID(ctx, user.ID) +} + func (s *Service) BeginPasskeyLogin(ctx context.Context, email string) (any, string, error) { user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email)) if err != nil { diff --git a/apps/server/internal/auth/service_test.go b/apps/server/internal/auth/service_test.go index 0e15a78..7b116c9 100644 --- a/apps/server/internal/auth/service_test.go +++ b/apps/server/internal/auth/service_test.go @@ -315,6 +315,21 @@ func TestPublicRegistrationCannotAttachCredentialsToExistingUser(t *testing.T) { } } +func TestCurrentUserCanBeginPasskeyRegistrationForExistingAccount(t *testing.T) { + service := setupAuthTestService(t) + ctx := context.Background() + + user := setupInitialAdmin(t, service, false) + principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour)) + options, challengeID, err := service.BeginCurrentUserPasskeyRegistration(ctx, principal) + if err != nil { + t.Fatalf("BeginCurrentUserPasskeyRegistration() error = %v", err) + } + if options == nil || challengeID == "" { + t.Fatalf("options = %#v, challengeID = %q; want registration options and challenge", options, challengeID) + } +} + func TestInitialSetupCanDisablePublicRegistration(t *testing.T) { service := setupAuthTestService(t) ctx := context.Background() diff --git a/apps/server/internal/httpserver/api_handlers_test.go b/apps/server/internal/httpserver/api_handlers_test.go index bcd9ff8..aa25e8d 100644 --- a/apps/server/internal/httpserver/api_handlers_test.go +++ b/apps/server/internal/httpserver/api_handlers_test.go @@ -301,6 +301,54 @@ func TestTokenCreationUsesDedicatedAccountPage(t *testing.T) { } } +func TestPasswordUserCanBeginPasskeyRegistrationFromAccount(t *testing.T) { + server := newAPITestServer(t) + + login := httptest.NewRecorder() + server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{ + "email": "editor@example.com", + "password": "very secure password", + })) + if login.Code != http.StatusOK || len(login.Result().Cookies()) == 0 { + t.Fatalf("login response = %d %q, want session cookie", login.Code, login.Body.String()) + } + session := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName) + + accountRequest := httptest.NewRequest(http.MethodGet, "/account", nil) + accountRequest.AddCookie(session) + account := httptest.NewRecorder() + server.handler.ServeHTTP(account, accountRequest) + if account.Code != http.StatusOK || !bytes.Contains(account.Body.Bytes(), []byte("data-passkey-add")) { + t.Fatalf("account page response = %d %q, want add-passkey form", account.Code, account.Body.String()) + } + + beginRequest := jsonRequest(http.MethodPost, "/api/auth/passkeys/me/register/begin", map[string]any{}) + beginRequest.AddCookie(session) + begin := httptest.NewRecorder() + server.handler.ServeHTTP(begin, beginRequest) + if begin.Code != http.StatusOK { + t.Fatalf("passkey begin response = %d %q", begin.Code, begin.Body.String()) + } + var body struct { + ChallengeID string `json:"challengeId"` + Options any `json:"options"` + } + if err := json.NewDecoder(begin.Body).Decode(&body); err != nil { + t.Fatalf("decode passkey begin response: %v", err) + } + if body.ChallengeID == "" || body.Options == nil { + t.Fatalf("passkey begin body = %#v, want challenge and options", body) + } + + publicBegin := httptest.NewRecorder() + server.handler.ServeHTTP(publicBegin, jsonRequest(http.MethodPost, "/api/auth/passkeys/register/begin", map[string]string{ + "email": "editor@example.com", + })) + if publicBegin.Code == http.StatusOK { + t.Fatalf("public passkey begin response = %d %q, want failure for existing account", publicBegin.Code, publicBegin.Body.String()) + } +} + func TestAdminUserAccessAndPasswordResetHTTPFlow(t *testing.T) { server := newAPITestServer(t) sender := &testEmailSender{} diff --git a/apps/server/internal/httpserver/auth_handlers.go b/apps/server/internal/httpserver/auth_handlers.go index 33876ac..dec7dae 100644 --- a/apps/server/internal/httpserver/auth_handlers.go +++ b/apps/server/internal/httpserver/auth_handlers.go @@ -334,6 +334,42 @@ func (s *Server) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Requ writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)}) } +func (s *Server) handleCurrentUserPasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) { + principal, _ := requirePrincipal(r) + if !requireSessionPrincipal(w, principal) { + return + } + options, challengeID, err := s.auth.BeginCurrentUserPasskeyRegistration(r.Context(), principal) + if err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"challengeId": challengeID, "options": options}) +} + +func (s *Server) handleCurrentUserPasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) { + principal, _ := requirePrincipal(r) + if !requireSessionPrincipal(w, principal) { + return + } + challengeID := r.URL.Query().Get("challengeId") + if challengeID == "" { + var req passkeyFinishRequest + _ = json.NewDecoder(r.Body).Decode(&req) + challengeID = req.ChallengeID + } + if challengeID == "" { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "challengeId is required"}) + return + } + user, err := s.auth.FinishCurrentUserPasskeyRegistration(r.Context(), principal, challengeID, r) + if err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)}) +} + func (s *Server) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) { if !s.allowAuthAttempt(w, r) { return diff --git a/apps/server/internal/httpserver/middleware.go b/apps/server/internal/httpserver/middleware.go index ffb116f..daffbaa 100644 --- a/apps/server/internal/httpserver/middleware.go +++ b/apps/server/internal/httpserver/middleware.go @@ -174,7 +174,7 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) { case path == "/permissions" && method == http.MethodPost: return auth.ScopeDocsWrite, true case strings.HasPrefix(path, "/api/auth/"): - if path == "/api/auth/me" || path == "/api/auth/logout" || path == "/api/auth/password/change" || path == "/api/auth/account" { + if path == "/api/auth/me" || path == "/api/auth/logout" || path == "/api/auth/password/change" || path == "/api/auth/account" || strings.HasPrefix(path, "/api/auth/passkeys/me/") { return auth.ScopeDocsRead, true } return "", false diff --git a/apps/server/internal/httpserver/server.go b/apps/server/internal/httpserver/server.go index 1d9bce0..4831268 100644 --- a/apps/server/internal/httpserver/server.go +++ b/apps/server/internal/httpserver/server.go @@ -124,6 +124,8 @@ func New(deps Dependencies) (http.Handler, error) { router.Post("/api/auth/logout", server.handleLogout) router.Post("/api/auth/password/change", server.handleChangePassword) router.Delete("/api/auth/account", server.handleDeleteAccount) + router.Post("/api/auth/passkeys/me/register/begin", server.handleCurrentUserPasskeyRegisterBegin) + router.Post("/api/auth/passkeys/me/register/finish", server.handleCurrentUserPasskeyRegisterFinish) router.Post("/api/auth/passkeys/register/begin", server.handlePasskeyRegisterBegin) router.Post("/api/auth/passkeys/register/finish", server.handlePasskeyRegisterFinish) router.Post("/api/auth/passkeys/login/begin", server.handlePasskeyLoginBegin) diff --git a/apps/server/internal/httpserver/static/auth.js b/apps/server/internal/httpserver/static/auth.js index 422fa77..964e131 100644 --- a/apps/server/internal/httpserver/static/auth.js +++ b/apps/server/internal/httpserver/static/auth.js @@ -251,6 +251,26 @@ } }); + document.querySelector("[data-passkey-add]")?.addEventListener("submit", async (event) => { + event.preventDefault(); + const support = await checkPasskeySupport(); + if (!support.supported) { + setStatus("[data-passkey-add-status]", support.reason, true); + return; + } + if (support.reason) { + console.warn("Passkey support:", support.reason); + } + try { + const begin = await postJSON("/api/auth/passkeys/me/register/begin", {}); + const credential = await navigator.credentials.create(normalizeCreateOptions(begin.options)); + await postJSON(`/api/auth/passkeys/me/register/finish?challengeId=${encodeURIComponent(begin.challengeId)}`, credentialToJSON(credential)); + setStatus("[data-passkey-add-status]", "Passkey added. You can use it the next time you sign in."); + } catch (error) { + setStatus("[data-passkey-add-status]", webAuthnErrorMessage(error), true); + } + }); + document.querySelector("[data-auth-logout]")?.addEventListener("click", async () => { try { await postJSON("/api/auth/logout", {}); diff --git a/apps/server/internal/httpserver/static/sw.js b/apps/server/internal/httpserver/static/sw.js index 8a354bd..e5470fe 100644 --- a/apps/server/internal/httpserver/static/sw.js +++ b/apps/server/internal/httpserver/static/sw.js @@ -1,4 +1,4 @@ -const STATIC_CACHE = "md-hub-static-v7"; +const STATIC_CACHE = "md-hub-static-v8"; const DB_NAME = "md-hub-cache"; const DB_VERSION = 3; const STORE_PAGES = "pages"; diff --git a/apps/server/internal/httpserver/templates/account.gohtml b/apps/server/internal/httpserver/templates/account.gohtml index a65fbe7..0addb56 100644 --- a/apps/server/internal/httpserver/templates/account.gohtml +++ b/apps/server/internal/httpserver/templates/account.gohtml @@ -66,6 +66,20 @@