From b5cd4501e5e49e06cfafc9cc6afb44f2c4af53da Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Thu, 16 Jul 2026 13:26:56 +0900 Subject: [PATCH] fix(passkey): serve flat WebAuthn options to register and username login go-webauthn marshals CredentialCreation/CredentialAssertion as {"publicKey": {...}}, but the panel's register (Account.tsx) and username-first login (Login.tsx) read the options flat (options.challenge, options.user.id), so base64urlToBytes(undefined) threw "Cannot read properties of undefined (reading 'replace')" and neither ceremony could start. Strip the envelope in the register-begin and username-login-begin handlers via a small unwrapPublicKey helper; discoverable login keeps the envelope because it reads options.publicKey.* plus a top-level options.login_id. The begin tests now feed a wrapped body and assert the handlers return it flat, so they genuinely exercise the unwrap. --- internal/api/handlers_passkey.go | 27 ++++++++++++++++++--- internal/api/handlers_passkey_login_test.go | 6 ++--- internal/api/handlers_passkey_test.go | 8 +++--- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/internal/api/handlers_passkey.go b/internal/api/handlers_passkey.go index 7dc3b44..d64f05a 100644 --- a/internal/api/handlers_passkey.go +++ b/internal/api/handlers_passkey.go @@ -225,6 +225,22 @@ func passkeyUserFor(p *Principal, creds []PasskeyCredential) PasskeyUser { return PasskeyUser{ID: p.UserID, Name: label, DisplayName: label, Credentials: creds} } +// unwrapPublicKey strips go-webauthn's {"publicKey": {...}} envelope so the register and +// username-login begin handlers return the FLAT options the panel reads (options.challenge, +// options.user.id, options.allowCredentials) rather than options.publicKey.challenge — the +// envelope is what made the panel crash on base64urlToBytes(undefined). Discoverable login +// keeps the envelope (it reads options.publicKey.*), so it does not call this. A body with +// no publicKey member is returned unchanged. +func unwrapPublicKey(options json.RawMessage) json.RawMessage { + var env struct { + PublicKey json.RawMessage `json:"publicKey"` + } + if err := json.Unmarshal(options, &env); err != nil || len(env.PublicKey) == 0 { + return options + } + return env.PublicKey +} + // handlePasskeyRegisterBegin mints a credential-creation challenge for the caller // (spec §14, external app face). It loads the passkeys the caller has already bound so // the ceremony excludes them (one authenticator binds once), asks the verifier for the @@ -257,9 +273,9 @@ func (a *API) handlePasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) writeError(w, r, err) return } - // The creation options are the WebAuthn {"publicKey": {...}} document the browser - // passes straight to navigator.credentials.create(); return them verbatim. - writeJSON(w, http.StatusOK, options) + // go-webauthn wraps the creation options as {"publicKey": {...}}; the panel's register + // flow reads them flat (options.challenge, options.user.id), so strip the envelope. + writeJSON(w, http.StatusOK, unwrapPublicKey(options)) } // passkeyFinishRequest is the finish body: the human nickname for the new passkey and @@ -521,7 +537,10 @@ func (a *API) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) { return } committed = true - writeJSON(w, http.StatusOK, options) + // go-webauthn wraps the assertion options as {"publicKey": {...}}; the panel's + // username-login flow reads them flat (options.challenge, options.allowCredentials), + // so strip the envelope. (Discoverable login keeps the envelope — see its handler.) + writeJSON(w, http.StatusOK, unwrapPublicKey(options)) } // passkeyLoginFinishRequest is the finish body: the email (to resolve the account, diff --git a/internal/api/handlers_passkey_login_test.go b/internal/api/handlers_passkey_login_test.go index 78496ee..8007a38 100644 --- a/internal/api/handlers_passkey_login_test.go +++ b/internal/api/handlers_passkey_login_test.go @@ -77,15 +77,15 @@ func TestPasskeyLoginVertical(t *testing.T) { api, repo, v := seedLoginPasskeyAPI(t) eh := api.ExternalHandler() - // 1) begin: options verbatim, exactly one login-purpose challenge stashed for u1, and + // 1) begin: options FLAT (envelope stripped), exactly one login-purpose challenge stashed for u1, and // the account's bound credential handed to the verifier (so the authenticator can be // asked to assert with a known key). w := do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"player@example.net"}`, jsonHeader) if w.Code != http.StatusOK { t.Fatalf("begin: code = %d, want 200 (%s)", w.Code, w.Body.String()) } - if b := acctBody(t, w); b["publicKey"] == nil { - t.Errorf("begin must return the assertion options verbatim, got %s", w.Body.String()) + if b := acctBody(t, w); b["challenge"] == nil || b["publicKey"] != nil { + t.Errorf("begin must return FLAT assertion options (top-level challenge, no publicKey envelope), got %s", w.Body.String()) } if v.lastUser.ID != "u1" { t.Errorf("begin passed user id %q, want u1", v.lastUser.ID) diff --git a/internal/api/handlers_passkey_test.go b/internal/api/handlers_passkey_test.go index 764f543..efb9ae7 100644 --- a/internal/api/handlers_passkey_test.go +++ b/internal/api/handlers_passkey_test.go @@ -54,14 +54,14 @@ func TestPasskeyRegisterVertical(t *testing.T) { } eh := newPasskeyAPI(repo, v, user) - // 1) begin returns the verifier's creation options verbatim and stashes exactly one - // challenge bound to the caller. + // 1) begin returns the creation options FLAT (envelope stripped for the panel) and + // stashes exactly one challenge bound to the caller. w := do(eh, "POST", "/api/v1/account/passkey/register/begin", `{}`, nil) if w.Code != http.StatusOK { t.Fatalf("begin: code = %d, want 200 (%s)", w.Code, w.Body.String()) } - if b := acctBody(t, w); b["publicKey"] == nil { - t.Errorf("begin must return the publicKey creation options verbatim, got %s", w.Body.String()) + if b := acctBody(t, w); b["challenge"] == nil || b["publicKey"] != nil { + t.Errorf("begin must return FLAT creation options (top-level challenge, no publicKey envelope), got %s", w.Body.String()) } if len(repo.passkeyChallenges) != 1 { t.Fatalf("begin must stash exactly one challenge, got %d", len(repo.passkeyChallenges))