Skip to content

Commit 2d8f2b6

Browse files
authored
fix(passkeys): modify the passkeys request and response shapes (#2475)
Modifies some of the passkeys request/response shapes for a cleaner interface and to better align with industry standards. In particular: The `/options` endpoints removes unnecessary nesting (a byproduct of serializing the go-webauthn object directly): ``` { "challenge_id": "some-challenge-id", "options": { "publicKey": { // ... the public key options }, } "expires_at": 1234567890 } ``` becomes: ``` { "challenge_id": "some-challenge-id", "options": { // ... the public key options } "expires_at": 1234567890 } ``` --- Rename the `credential` in the `/verify` endpoint payload from `credential_response` to `credential`: ``` { "challenge_id": "some-challenge-id", "credential_response": { // ... the response from the client } } ``` becomes ``` { "challenge_id": "some-challenge-id", "credential": { // ... the response from the client } } ``` --- Finally, remove the `backed_up`, `backup_eligible`, and `transports` fields from the `/verify` response upon registration. We can later expose them consistently across the API responses if/when needed.
1 parent d03d796 commit 2d8f2b6

5 files changed

Lines changed: 71 additions & 77 deletions

internal/api/passkey_authentication.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,15 @@ import (
1717

1818
// PasskeyAuthenticationOptionsResponse is the response body for POST /passkeys/authentication/options.
1919
type PasskeyAuthenticationOptionsResponse struct {
20-
ChallengeID string `json:"challenge_id"`
21-
Options *protocol.CredentialAssertion `json:"options"`
22-
ExpiresAt int64 `json:"expires_at"`
20+
ChallengeID string `json:"challenge_id"`
21+
Options *protocol.PublicKeyCredentialRequestOptions `json:"options"`
22+
ExpiresAt int64 `json:"expires_at"`
2323
}
2424

2525
// PasskeyAuthenticationVerifyParams is the request body for POST /passkeys/authentication/verify.
2626
type PasskeyAuthenticationVerifyParams struct {
27-
ChallengeID string `json:"challenge_id"`
28-
CredentialResponse json.RawMessage `json:"credential_response"`
27+
ChallengeID string `json:"challenge_id"`
28+
Credential json.RawMessage `json:"credential"`
2929
}
3030

3131
// PasskeyAuthenticationOptions handles POST /passkeys/authentication/options.
@@ -59,7 +59,7 @@ func (a *API) PasskeyAuthenticationOptions(w http.ResponseWriter, r *http.Reques
5959

6060
return sendJSON(w, http.StatusOK, &PasskeyAuthenticationOptionsResponse{
6161
ChallengeID: challenge.ID.String(),
62-
Options: options,
62+
Options: &options.Response,
6363
ExpiresAt: expiresAt.Unix(),
6464
})
6565
}
@@ -83,8 +83,8 @@ func (a *API) PasskeyAuthenticationVerify(w http.ResponseWriter, r *http.Request
8383
if params.ChallengeID == "" {
8484
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "challenge_id is required")
8585
}
86-
if params.CredentialResponse == nil {
87-
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "credential_response is required")
86+
if params.Credential == nil {
87+
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "credential is required")
8888
}
8989

9090
challengeID, err := uuid.FromString(params.ChallengeID)
@@ -105,7 +105,7 @@ func (a *API) PasskeyAuthenticationVerify(w http.ResponseWriter, r *http.Request
105105
return apierrors.NewBadRequestError(apierrors.ErrorCodeWebAuthnChallengeExpired, "Challenge has expired")
106106
}
107107

108-
parsedResponse, err := parseCredentialAssertionResponse(params.CredentialResponse)
108+
parsedResponse, err := parseCredentialAssertionResponse(params.Credential)
109109
if err != nil {
110110
return apierrors.NewBadRequestError(apierrors.ErrorCodeWebAuthnVerificationFailed, "Invalid credential response").WithInternalError(err)
111111
}

internal/api/passkey_authentication_test.go

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,16 @@ func (ts *PasskeyTestSuite) TestDiscoverableAuthenticationHappyPath() {
2727
ts.NotZero(optionsResp.ExpiresAt)
2828

2929
// Verify allowCredentials is empty (discoverable)
30-
ts.Empty(optionsResp.Options.Response.AllowedCredentials)
30+
ts.Empty(optionsResp.Options.AllowedCredentials)
3131

3232
// Step 2: Simulate the authenticator creating an assertion
3333
assertionResp, err := authenticator.getAssertion(optionsResp.Options)
3434
require.NoError(ts.T(), err)
3535

3636
// Step 3: Verify the authentication
3737
w = ts.makeRequest(http.MethodPost, "http://localhost/passkeys/authentication/verify", map[string]any{
38-
"challenge_id": optionsResp.ChallengeID,
39-
"credential_response": json.RawMessage(assertionResp.JSON),
38+
"challenge_id": optionsResp.ChallengeID,
39+
"credential": json.RawMessage(assertionResp.JSON),
4040
})
4141
ts.Require().Equal(http.StatusOK, w.Code)
4242

@@ -71,8 +71,8 @@ func (ts *PasskeyTestSuite) TestDiscoverableAuthenticationUnconfirmedEmail() {
7171

7272
// Verify — should fail with email_not_confirmed
7373
w = ts.makeRequest(http.MethodPost, "http://localhost/passkeys/authentication/verify", map[string]any{
74-
"challenge_id": optionsResp.ChallengeID,
75-
"credential_response": json.RawMessage(assertionResp.JSON),
74+
"challenge_id": optionsResp.ChallengeID,
75+
"credential": json.RawMessage(assertionResp.JSON),
7676
})
7777
ts.Equal(http.StatusForbidden, w.Code)
7878
var errResp map[string]any
@@ -100,8 +100,8 @@ func (ts *PasskeyTestSuite) TestDiscoverableAuthenticationBannedUser() {
100100

101101
// Verify — should fail with user_banned
102102
w = ts.makeRequest(http.MethodPost, "http://localhost/passkeys/authentication/verify", map[string]any{
103-
"challenge_id": optionsResp.ChallengeID,
104-
"credential_response": json.RawMessage(assertionResp.JSON),
103+
"challenge_id": optionsResp.ChallengeID,
104+
"credential": json.RawMessage(assertionResp.JSON),
105105
})
106106
ts.Equal(http.StatusForbidden, w.Code)
107107
var errResp map[string]any
@@ -120,8 +120,8 @@ func (ts *PasskeyTestSuite) TestDiscoverableAuthenticationChallengeExpired() {
120120
require.NoError(ts.T(), ts.API.db.Create(challenge))
121121

122122
w := ts.makeRequest(http.MethodPost, "http://localhost/passkeys/authentication/verify", map[string]any{
123-
"challenge_id": challenge.ID.String(),
124-
"credential_response": map[string]any{},
123+
"challenge_id": challenge.ID.String(),
124+
"credential": map[string]any{},
125125
})
126126
ts.Equal(http.StatusBadRequest, w.Code)
127127
var errResp map[string]any
@@ -132,8 +132,8 @@ func (ts *PasskeyTestSuite) TestDiscoverableAuthenticationChallengeExpired() {
132132
// TestDiscoverableAuthenticationChallengeNotFound tests that a missing challenge is rejected.
133133
func (ts *PasskeyTestSuite) TestDiscoverableAuthenticationChallengeNotFound() {
134134
w := ts.makeRequest(http.MethodPost, "http://localhost/passkeys/authentication/verify", map[string]any{
135-
"challenge_id": uuid.Must(uuid.NewV4()).String(),
136-
"credential_response": map[string]any{},
135+
"challenge_id": uuid.Must(uuid.NewV4()).String(),
136+
"credential": map[string]any{},
137137
})
138138
ts.Equal(http.StatusBadRequest, w.Code)
139139
var errResp map[string]any
@@ -154,8 +154,8 @@ func (ts *PasskeyTestSuite) TestDiscoverableAuthenticationInvalidAssertion() {
154154

155155
// Send garbage as credential response
156156
w = ts.makeRequest(http.MethodPost, "http://localhost/passkeys/authentication/verify", map[string]any{
157-
"challenge_id": optionsResp.ChallengeID,
158-
"credential_response": map[string]any{"garbage": true},
157+
"challenge_id": optionsResp.ChallengeID,
158+
"credential": map[string]any{"garbage": true},
159159
})
160160
ts.Equal(http.StatusBadRequest, w.Code)
161161
var errResp map[string]any
@@ -176,7 +176,7 @@ func (ts *PasskeyTestSuite) TestDiscoverableAuthenticationUnknownCredential() {
176176
// because the userHandle points to a non-existent user.
177177
w = ts.makeRequest(http.MethodPost, "http://localhost/passkeys/authentication/verify", map[string]any{
178178
"challenge_id": optionsResp.ChallengeID,
179-
"credential_response": map[string]any{
179+
"credential": map[string]any{
180180
"id": "ZmFrZS1jcmVkZW50aWFsLWlk",
181181
"type": "public-key",
182182
"rawId": "ZmFrZS1jcmVkZW50aWFsLWlk",
@@ -307,8 +307,8 @@ func (ts *PasskeyTestSuite) registerPasskey() (*virtualAuthenticator, *PasskeyMe
307307
require.NoError(ts.T(), err)
308308

309309
w = ts.makeRequest(http.MethodPost, "http://localhost/passkeys/registration/verify", map[string]any{
310-
"challenge_id": optionsResp.ChallengeID,
311-
"credential_response": json.RawMessage(credResp.JSON),
310+
"challenge_id": optionsResp.ChallengeID,
311+
"credential": json.RawMessage(credResp.JSON),
312312
}, withBearerToken(token))
313313
ts.Require().Equal(http.StatusOK, w.Code)
314314

internal/api/passkey_registration.go

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,22 @@ type PasskeyRegistrationOptionsParams struct{}
1919

2020
// PasskeyRegistrationOptionsResponse is the response body for POST /passkeys/registration/options.
2121
type PasskeyRegistrationOptionsResponse struct {
22-
ChallengeID string `json:"challenge_id"`
23-
Options *protocol.CredentialCreation `json:"options"`
24-
ExpiresAt int64 `json:"expires_at"`
22+
ChallengeID string `json:"challenge_id"`
23+
Options *protocol.PublicKeyCredentialCreationOptions `json:"options"`
24+
ExpiresAt int64 `json:"expires_at"`
2525
}
2626

2727
// PasskeyRegistrationVerifyParams is the request body for POST /passkeys/registration/verify.
2828
type PasskeyRegistrationVerifyParams struct {
29-
ChallengeID string `json:"challenge_id"`
30-
CredentialResponse json.RawMessage `json:"credential_response"`
29+
ChallengeID string `json:"challenge_id"`
30+
Credential json.RawMessage `json:"credential"`
3131
}
3232

3333
// PasskeyMetadataResponse is the response body for successful passkey creation.
3434
type PasskeyMetadataResponse struct {
35-
ID string `json:"id"`
36-
FriendlyName string `json:"friendly_name,omitempty"`
37-
CreatedAt time.Time `json:"created_at"`
38-
BackupEligible bool `json:"backup_eligible"`
39-
BackedUp bool `json:"backed_up"`
40-
Transports []protocol.AuthenticatorTransport `json:"transports"`
35+
ID string `json:"id"`
36+
FriendlyName string `json:"friendly_name,omitempty"`
37+
CreatedAt time.Time `json:"created_at"`
4138
}
4239

4340
// PasskeyRegistrationOptions handles POST /passkeys/registration/options.
@@ -100,7 +97,7 @@ func (a *API) PasskeyRegistrationOptions(w http.ResponseWriter, r *http.Request)
10097

10198
return sendJSON(w, http.StatusOK, &PasskeyRegistrationOptionsResponse{
10299
ChallengeID: challenge.ID.String(),
103-
Options: options,
100+
Options: &options.Response,
104101
ExpiresAt: expiresAt.Unix(),
105102
})
106103
}
@@ -129,8 +126,8 @@ func (a *API) PasskeyRegistrationVerify(w http.ResponseWriter, r *http.Request)
129126
if params.ChallengeID == "" {
130127
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "challenge_id is required")
131128
}
132-
if params.CredentialResponse == nil {
133-
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "credential_response is required")
129+
if params.Credential == nil {
130+
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "credential is required")
134131
}
135132

136133
challengeID, err := uuid.FromString(params.ChallengeID)
@@ -153,7 +150,7 @@ func (a *API) PasskeyRegistrationVerify(w http.ResponseWriter, r *http.Request)
153150
}
154151

155152
// Parse the credential creation response from the JSON params
156-
parsedResponse, err := parseCredentialCreationResponse(params.CredentialResponse)
153+
parsedResponse, err := parseCredentialCreationResponse(params.Credential)
157154
if err != nil {
158155
return apierrors.NewBadRequestError(apierrors.ErrorCodeWebAuthnVerificationFailed, "Invalid credential response").WithInternalError(err)
159156
}
@@ -213,12 +210,9 @@ func (a *API) PasskeyRegistrationVerify(w http.ResponseWriter, r *http.Request)
213210
}
214211

215212
return sendJSON(w, http.StatusOK, &PasskeyMetadataResponse{
216-
ID: passkeyCredential.ID.String(),
217-
FriendlyName: passkeyCredential.FriendlyName,
218-
CreatedAt: passkeyCredential.CreatedAt,
219-
BackupEligible: passkeyCredential.BackupEligible,
220-
BackedUp: passkeyCredential.BackedUp,
221-
Transports: []protocol.AuthenticatorTransport(passkeyCredential.Transports),
213+
ID: passkeyCredential.ID.String(),
214+
FriendlyName: passkeyCredential.FriendlyName,
215+
CreatedAt: passkeyCredential.CreatedAt,
222216
})
223217
}
224218

internal/api/passkey_registration_test.go

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ func (ts *PasskeyTestSuite) TestRegisterPasskeyHappyPath() {
3535

3636
// Step 3: Verify the registration
3737
w = ts.makeRequest(http.MethodPost, "http://localhost/passkeys/registration/verify", map[string]any{
38-
"challenge_id": optionsResp.ChallengeID,
39-
"credential_response": json.RawMessage(credResp.JSON),
38+
"challenge_id": optionsResp.ChallengeID,
39+
"credential": json.RawMessage(credResp.JSON),
4040
}, withBearerToken(token))
4141
ts.Require().Equal(http.StatusOK, w.Code)
4242

@@ -109,8 +109,8 @@ func (ts *PasskeyTestSuite) TestRegistrationOptionsWithExistingPasskeys() {
109109

110110
// The exclusion list should contain the existing credential
111111
ts.Require().NotNil(resp.Options)
112-
ts.Require().NotNil(resp.Options.Response.CredentialExcludeList)
113-
ts.Len(resp.Options.Response.CredentialExcludeList, 1)
112+
ts.Require().NotNil(resp.Options.CredentialExcludeList)
113+
ts.Len(resp.Options.CredentialExcludeList, 1)
114114
}
115115

116116
// TestRegistrationOptionsTooManyPasskeys tests that the limit is enforced.
@@ -204,15 +204,15 @@ func (ts *PasskeyTestSuite) TestRegisterVerifyCapEnforcedAtVerifyTime() {
204204

205205
// Verify the first challenge — should succeed
206206
v1 := ts.makeRequest(http.MethodPost, "http://localhost/passkeys/registration/verify", map[string]any{
207-
"challenge_id": opts1.ChallengeID,
208-
"credential_response": json.RawMessage(cred1.JSON),
207+
"challenge_id": opts1.ChallengeID,
208+
"credential": json.RawMessage(cred1.JSON),
209209
}, withBearerToken(token))
210210
ts.Require().Equal(http.StatusOK, v1.Code)
211211

212212
// Verify the second challenge — should fail with too_many_passkeys
213213
v2 := ts.makeRequest(http.MethodPost, "http://localhost/passkeys/registration/verify", map[string]any{
214-
"challenge_id": opts2.ChallengeID,
215-
"credential_response": json.RawMessage(cred2.JSON),
214+
"challenge_id": opts2.ChallengeID,
215+
"credential": json.RawMessage(cred2.JSON),
216216
}, withBearerToken(token))
217217
ts.Equal(http.StatusUnprocessableEntity, v2.Code)
218218
var errResp map[string]any
@@ -224,8 +224,8 @@ func (ts *PasskeyTestSuite) TestRegisterVerifyCapEnforcedAtVerifyTime() {
224224
func (ts *PasskeyTestSuite) TestRegisterVerifyChallengeNotFound() {
225225
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
226226
w := ts.makeRequest(http.MethodPost, "http://localhost/passkeys/registration/verify", map[string]any{
227-
"challenge_id": uuid.Must(uuid.NewV4()).String(),
228-
"credential_response": map[string]any{},
227+
"challenge_id": uuid.Must(uuid.NewV4()).String(),
228+
"credential": map[string]any{},
229229
}, withBearerToken(token))
230230

231231
ts.Equal(http.StatusBadRequest, w.Code)
@@ -246,8 +246,8 @@ func (ts *PasskeyTestSuite) TestRegisterVerifyChallengeExpired() {
246246

247247
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
248248
w := ts.makeRequest(http.MethodPost, "http://localhost/passkeys/registration/verify", map[string]any{
249-
"challenge_id": challenge.ID.String(),
250-
"credential_response": map[string]any{},
249+
"challenge_id": challenge.ID.String(),
250+
"credential": map[string]any{},
251251
}, withBearerToken(token))
252252

253253
ts.Equal(http.StatusBadRequest, w.Code)
@@ -272,8 +272,8 @@ func (ts *PasskeyTestSuite) TestRegisterVerifyWrongUser() {
272272

273273
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
274274
w := ts.makeRequest(http.MethodPost, "http://localhost/passkeys/registration/verify", map[string]any{
275-
"challenge_id": challenge.ID.String(),
276-
"credential_response": map[string]any{},
275+
"challenge_id": challenge.ID.String(),
276+
"credential": map[string]any{},
277277
}, withBearerToken(token))
278278

279279
ts.Equal(http.StatusBadRequest, w.Code)
@@ -293,11 +293,11 @@ func (ts *PasskeyTestSuite) TestRegisterVerifyMissingFields() {
293293
{
294294
desc: "missing challenge_id",
295295
body: map[string]any{
296-
"credential_response": map[string]any{},
296+
"credential": map[string]any{},
297297
},
298298
},
299299
{
300-
desc: "missing credential_response",
300+
desc: "missing credential",
301301
body: map[string]any{
302302
"challenge_id": uuid.Must(uuid.NewV4()).String(),
303303
},
@@ -316,8 +316,8 @@ func (ts *PasskeyTestSuite) TestRegisterVerifyMissingFields() {
316316
func (ts *PasskeyTestSuite) TestRegisterVerifyInvalidChallengeID() {
317317
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
318318
w := ts.makeRequest(http.MethodPost, "http://localhost/passkeys/registration/verify", map[string]any{
319-
"challenge_id": "not-a-uuid",
320-
"credential_response": map[string]any{},
319+
"challenge_id": "not-a-uuid",
320+
"credential": map[string]any{},
321321
}, withBearerToken(token))
322322

323323
ts.Equal(http.StatusBadRequest, w.Code)
@@ -335,8 +335,8 @@ func (ts *PasskeyTestSuite) TestRegisterVerifyWrongChallengeType() {
335335

336336
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
337337
w := ts.makeRequest(http.MethodPost, "http://localhost/passkeys/registration/verify", map[string]any{
338-
"challenge_id": challenge.ID.String(),
339-
"credential_response": map[string]any{},
338+
"challenge_id": challenge.ID.String(),
339+
"credential": map[string]any{},
340340
}, withBearerToken(token))
341341

342342
ts.Equal(http.StatusBadRequest, w.Code)
@@ -345,8 +345,8 @@ func (ts *PasskeyTestSuite) TestRegisterVerifyWrongChallengeType() {
345345
// TestRegisterVerifyUnauthenticated tests that unauthenticated requests are rejected.
346346
func (ts *PasskeyTestSuite) TestRegisterVerifyUnauthenticated() {
347347
w := ts.makeRequest(http.MethodPost, "http://localhost/passkeys/registration/verify", map[string]any{
348-
"challenge_id": uuid.Must(uuid.NewV4()).String(),
349-
"credential_response": map[string]any{},
348+
"challenge_id": uuid.Must(uuid.NewV4()).String(),
349+
"credential": map[string]any{},
350350
})
351351
ts.Equal(http.StatusUnauthorized, w.Code)
352352
}
@@ -358,8 +358,8 @@ func (ts *PasskeyTestSuite) TestRegisterVerifySSOUser() {
358358

359359
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
360360
w := ts.makeRequest(http.MethodPost, "http://localhost/passkeys/registration/verify", map[string]any{
361-
"challenge_id": uuid.Must(uuid.NewV4()).String(),
362-
"credential_response": map[string]any{},
361+
"challenge_id": uuid.Must(uuid.NewV4()).String(),
362+
"credential": map[string]any{},
363363
}, withBearerToken(token))
364364

365365
ts.Equal(http.StatusUnprocessableEntity, w.Code)

0 commit comments

Comments
 (0)