Skip to content

Commit 94d303a

Browse files
dreamhunter2333awsl233777claude
authored
feat: support passkey discoverable login (no username required) (#336)
* feat: support passkey discoverable login (no username required) - Backend: add BeginDiscoverableLogin/FinishDiscoverableLogin flow when username is empty, resolving user from userHandle in credential response - Backend: change ResidentKey requirement from Preferred to Required so new passkeys always support discoverable login - Frontend: remove username guard on passkey login button, allow empty username to trigger discoverable flow - Transport: make startPasskeyLogin username parameter optional - i18n: remove unused passkeyUsernameRequired key - Add Playwright E2E test project for local passkey testing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve strict mode violation in playwright passkey test Use .first() for Close button locator when dialog has multiple matching buttons. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add passkey discoverable login test screenshot * revert: remove test screenshot from repo * refactor: harden discoverable login - nil guard and remove redundant parse - Add nil check on discoveredUser after FinishDiscoverableLogin - Reuse credentials parsed in the callback instead of parsing twice Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: awsl23377 <awsl233777@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 143e61c commit 94d303a

File tree

9 files changed

+341
-44
lines changed

9 files changed

+341
-44
lines changed

internal/handler/auth_passkey.go

Lines changed: 100 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"net"
99
"net/http"
10+
"strconv"
1011
"strings"
1112
"sync"
1213
"time"
@@ -234,7 +235,7 @@ func (h *AuthHandler) handlePasskeyRegisterOptions(w http.ResponseWriter, r *htt
234235

235236
options := []webauthn.RegistrationOption{
236237
webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
237-
ResidentKey: protocol.ResidentKeyRequirementPreferred,
238+
ResidentKey: protocol.ResidentKeyRequirementRequired,
238239
UserVerification: protocol.VerificationRequired,
239240
}),
240241
webauthn.WithConveyancePreference(protocol.PreferNoAttestation),
@@ -366,11 +367,39 @@ func (h *AuthHandler) handlePasskeyLoginOptions(w http.ResponseWriter, r *http.R
366367
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
367368
return
368369
}
370+
371+
wAuthn, err := newWebAuthnFromRequest(r)
372+
if err != nil {
373+
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
374+
return
375+
}
376+
369377
if body.Username == "" {
370-
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username is required"})
378+
// Discoverable login: no username provided, let the authenticator choose
379+
assertion, session, err := wAuthn.BeginDiscoverableLogin(
380+
webauthn.WithUserVerification(protocol.VerificationRequired),
381+
)
382+
if err != nil {
383+
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to generate passkey login options"})
384+
return
385+
}
386+
387+
sessionID := h.passkeyStore.put(passkeySession{
388+
Type: passkeySessionTypeLogin,
389+
UserID: 0,
390+
TenantID: 0,
391+
Session: *session,
392+
})
393+
394+
writeJSON(w, http.StatusOK, map[string]any{
395+
"success": true,
396+
"sessionID": sessionID,
397+
"options": assertion.Response,
398+
})
371399
return
372400
}
373401

402+
// Username-based login: existing flow
374403
user, err := h.userRepo.GetByUsername(body.Username)
375404
if err != nil {
376405
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
@@ -390,12 +419,6 @@ func (h *AuthHandler) handlePasskeyLoginOptions(w http.ResponseWriter, r *http.R
390419
return
391420
}
392421

393-
wAuthn, err := newWebAuthnFromRequest(r)
394-
if err != nil {
395-
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
396-
return
397-
}
398-
399422
assertion, session, err := wAuthn.BeginLogin(
400423
newWebAuthnUser(user, credentials),
401424
webauthn.WithUserVerification(protocol.VerificationRequired),
@@ -448,25 +471,6 @@ func (h *AuthHandler) handlePasskeyLoginVerify(w http.ResponseWriter, r *http.Re
448471
return
449472
}
450473

451-
user, err := h.userRepo.GetByID(session.TenantID, session.UserID)
452-
if err != nil {
453-
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
454-
return
455-
}
456-
if !ensureUserIsActive(w, user) {
457-
return
458-
}
459-
460-
credentials, err := parsePasskeyCredentials(user.PasskeyCredentials)
461-
if err != nil {
462-
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "invalid stored passkey credentials"})
463-
return
464-
}
465-
if len(credentials) == 0 {
466-
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
467-
return
468-
}
469-
470474
wAuthn, err := newWebAuthnFromRequest(r)
471475
if err != nil {
472476
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
@@ -479,13 +483,75 @@ func (h *AuthHandler) handlePasskeyLoginVerify(w http.ResponseWriter, r *http.Re
479483
return
480484
}
481485

482-
validatedCredential, err := wAuthn.FinishLogin(
483-
newWebAuthnUser(user, credentials),
484-
session.Session,
485-
credentialReq,
486-
)
487-
if err != nil {
488-
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid passkey credential"})
486+
var user *domain.User
487+
var credentials []webauthn.Credential
488+
var validatedCredential *webauthn.Credential
489+
490+
if session.UserID == 0 {
491+
// Discoverable login: resolve user from userHandle in the credential response
492+
var discoveredUser *domain.User
493+
var discoveredCreds []webauthn.Credential
494+
validatedCredential, err = wAuthn.FinishDiscoverableLogin(
495+
func(rawID, userHandle []byte) (webauthn.User, error) {
496+
userID, parseErr := strconv.ParseUint(string(userHandle), 10, 64)
497+
if parseErr != nil {
498+
return nil, fmt.Errorf("invalid user handle")
499+
}
500+
u, dbErr := h.userRepo.GetByID(0, userID)
501+
if dbErr != nil {
502+
return nil, fmt.Errorf("user not found")
503+
}
504+
creds, credErr := parsePasskeyCredentials(u.PasskeyCredentials)
505+
if credErr != nil {
506+
return nil, fmt.Errorf("invalid stored passkey credentials")
507+
}
508+
discoveredUser = u
509+
discoveredCreds = creds
510+
return newWebAuthnUser(u, creds), nil
511+
},
512+
session.Session,
513+
credentialReq,
514+
)
515+
if err != nil {
516+
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid passkey credential"})
517+
return
518+
}
519+
if discoveredUser == nil {
520+
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid passkey credential"})
521+
return
522+
}
523+
user = discoveredUser
524+
credentials = discoveredCreds
525+
} else {
526+
// Username-based login: existing flow
527+
user, err = h.userRepo.GetByID(session.TenantID, session.UserID)
528+
if err != nil {
529+
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
530+
return
531+
}
532+
533+
credentials, err = parsePasskeyCredentials(user.PasskeyCredentials)
534+
if err != nil {
535+
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "invalid stored passkey credentials"})
536+
return
537+
}
538+
if len(credentials) == 0 {
539+
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
540+
return
541+
}
542+
543+
validatedCredential, err = wAuthn.FinishLogin(
544+
newWebAuthnUser(user, credentials),
545+
session.Session,
546+
credentialReq,
547+
)
548+
if err != nil {
549+
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid passkey credential"})
550+
return
551+
}
552+
}
553+
554+
if !ensureUserIsActive(w, user) {
489555
return
490556
}
491557

tests/e2e/playwright/package.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "maxx-e2e-playwright",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"test": "node test-passkey-discoverable.mjs",
7+
"test:headed": "HEADED=1 node test-passkey-discoverable.mjs"
8+
},
9+
"dependencies": {
10+
"playwright": "^1.52.0"
11+
}
12+
}

tests/e2e/playwright/pnpm-lock.yaml

Lines changed: 43 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)