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.
This commit is contained in:
3 files changed
+30
-11
No files matched your search
@@ -225,6 +225,22 @@ func passkeyUserFor(p *Principal, creds []PasskeyCredential) PasskeyUser {
|
|||||||
return PasskeyUser{ID: p.UserID, Name: label, DisplayName: label, Credentials: creds}
|
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
|
// handlePasskeyRegisterBegin mints a credential-creation challenge for the caller
|
||||||
// (spec §14, external app face). It loads the passkeys the caller has already bound so
|
// (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
|
// 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)
|
writeError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The creation options are the WebAuthn {"publicKey": {...}} document the browser
|
// go-webauthn wraps the creation options as {"publicKey": {...}}; the panel's register
|
||||||
// passes straight to navigator.credentials.create(); return them verbatim.
|
// flow reads them flat (options.challenge, options.user.id), so strip the envelope.
|
||||||
writeJSON(w, http.StatusOK, options)
|
writeJSON(w, http.StatusOK, unwrapPublicKey(options))
|
||||||
}
|
}
|
||||||
|
|
||||||
// passkeyFinishRequest is the finish body: the human nickname for the new passkey and
|
// 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
|
return
|
||||||
}
|
}
|
||||||
committed = true
|
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,
|
// passkeyLoginFinishRequest is the finish body: the email (to resolve the account,
|
||||||
|
|||||||
@@ -77,15 +77,15 @@ func TestPasskeyLoginVertical(t *testing.T) {
|
|||||||
api, repo, v := seedLoginPasskeyAPI(t)
|
api, repo, v := seedLoginPasskeyAPI(t)
|
||||||
eh := api.ExternalHandler()
|
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
|
// the account's bound credential handed to the verifier (so the authenticator can be
|
||||||
// asked to assert with a known key).
|
// asked to assert with a known key).
|
||||||
w := do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"[email protected]"}`, jsonHeader)
|
w := do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"[email protected]"}`, jsonHeader)
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
t.Fatalf("begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
if b := acctBody(t, w); b["publicKey"] == nil {
|
if b := acctBody(t, w); b["challenge"] == nil || b["publicKey"] != nil {
|
||||||
t.Errorf("begin must return the assertion options verbatim, got %s", w.Body.String())
|
t.Errorf("begin must return FLAT assertion options (top-level challenge, no publicKey envelope), got %s", w.Body.String())
|
||||||
}
|
}
|
||||||
if v.lastUser.ID != "u1" {
|
if v.lastUser.ID != "u1" {
|
||||||
t.Errorf("begin passed user id %q, want u1", v.lastUser.ID)
|
t.Errorf("begin passed user id %q, want u1", v.lastUser.ID)
|
||||||
|
|||||||
@@ -54,14 +54,14 @@ func TestPasskeyRegisterVertical(t *testing.T) {
|
|||||||
}
|
}
|
||||||
eh := newPasskeyAPI(repo, v, user)
|
eh := newPasskeyAPI(repo, v, user)
|
||||||
|
|
||||||
// 1) begin returns the verifier's creation options verbatim and stashes exactly one
|
// 1) begin returns the creation options FLAT (envelope stripped for the panel) and
|
||||||
// challenge bound to the caller.
|
// stashes exactly one challenge bound to the caller.
|
||||||
w := do(eh, "POST", "/api/v1/account/passkey/register/begin", `{}`, nil)
|
w := do(eh, "POST", "/api/v1/account/passkey/register/begin", `{}`, nil)
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
t.Fatalf("begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
if b := acctBody(t, w); b["publicKey"] == nil {
|
if b := acctBody(t, w); b["challenge"] == nil || b["publicKey"] != nil {
|
||||||
t.Errorf("begin must return the publicKey creation options verbatim, got %s", w.Body.String())
|
t.Errorf("begin must return FLAT creation options (top-level challenge, no publicKey envelope), got %s", w.Body.String())
|
||||||
}
|
}
|
||||||
if len(repo.passkeyChallenges) != 1 {
|
if len(repo.passkeyChallenges) != 1 {
|
||||||
t.Fatalf("begin must stash exactly one challenge, got %d", len(repo.passkeyChallenges))
|
t.Fatalf("begin must stash exactly one challenge, got %d", len(repo.passkeyChallenges))
|
||||||
|
|||||||
Reference in new issue
Block a user