Skip to content

Commit 68cb8d2

Browse files
authored
feat: webauthn support schema changes, update openapi.yaml (#2163)
## What kind of change does this PR introduce? Feature improvement / API cleanup ## What is the current behavior? - The API returns credential_creation_options and credential_request_options as separate fields at the root level, requiring clients to check which is null - OpenAPI spec doesn't match actual server output (missing publicKey wrapper that go-webauthn library adds) - Field naming inconsistent with W3C spec (web_authn vs standard webauthn) ## What is the new behavior? 1. Challenge response structure changed to discriminated union: - Before: Check null fields {credential_creation_options?: ..., credential_request_options?: ...} - After: Single typed field {type: "create" | "request", credential_options: {publicKey: ...}} 2. Verify request structure unified: - Before: {creation_response?: ..., assertion_response?: ...} - After: {type: "create" | "request", credential_response: ...} 3. RPOrigins changed from comma-separated string to string array (matches go-webauthn v3 expectations) ## Additional context This makes the PR for the auth-js library easier.
1 parent bd80df8 commit 68cb8d2

File tree

3 files changed

+209
-166
lines changed

3 files changed

+209
-166
lines changed

internal/api/mfa.go

Lines changed: 52 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -54,56 +54,51 @@ type EnrollFactorResponse struct {
5454

5555
type ChallengeFactorParams struct {
5656
Channel string `json:"channel"`
57-
WebAuthn *WebAuthnParams `json:"web_authn,omitempty"`
57+
WebAuthn *WebAuthnParams `json:"webauthn,omitempty"`
5858
}
5959

6060
type VerifyFactorParams struct {
6161
ChallengeID uuid.UUID `json:"challenge_id"`
6262
Code string `json:"code"`
63-
WebAuthn *WebAuthnParams `json:"web_authn,omitempty"`
63+
WebAuthn *WebAuthnParams `json:"webauthn,omitempty"`
6464
}
6565

6666
type ChallengeFactorResponse struct {
67-
ID uuid.UUID `json:"id"`
68-
Type string `json:"type"`
69-
ExpiresAt int64 `json:"expires_at,omitempty"`
70-
CredentialRequestOptions *wbnprotocol.CredentialAssertion `json:"credential_request_options,omitempty"`
71-
CredentialCreationOptions *wbnprotocol.CredentialCreation `json:"credential_creation_options,omitempty"`
67+
ID uuid.UUID `json:"id"`
68+
Type string `json:"type"`
69+
ExpiresAt int64 `json:"expires_at,omitempty"`
70+
WebAuthn *WebAuthnChallengeData `json:"webauthn,omitempty"`
7271
}
7372

74-
type UnenrollFactorResponse struct {
75-
ID uuid.UUID `json:"id"`
73+
type WebAuthnChallengeData struct {
74+
Type string `json:"type"` // "create" or "request"
75+
CredentialOptions interface{} `json:"credential_options"`
7676
}
7777

7878
type WebAuthnParams struct {
79-
RPID string `json:"rp_id,omitempty"`
80-
// Can encode multiple origins as comma separated values like: "origin1,origin2"
81-
RPOrigins string `json:"rp_origins,omitempty"`
82-
AssertionResponse json.RawMessage `json:"assertion_response,omitempty"`
83-
CreationResponse json.RawMessage `json:"creation_response,omitempty"`
79+
RPID string `json:"rpId,omitempty"`
80+
RPOrigins []string `json:"rpOrigins,omitempty"`
81+
Type string `json:"type"` // "create" or "request"
82+
CredentialResponse json.RawMessage `json:"credential_response"`
8483
}
8584

86-
func (w *WebAuthnParams) GetRPOrigins() []string {
87-
if w.RPOrigins == "" {
88-
return nil
89-
}
90-
return strings.Split(w.RPOrigins, ",")
85+
type UnenrollFactorResponse struct {
86+
ID uuid.UUID `json:"id"`
9187
}
9288

9389
func (w *WebAuthnParams) ToConfig() (*webauthn.WebAuthn, error) {
9490
if w.RPID == "" {
9591
return nil, fmt.Errorf("webAuthn RP ID cannot be empty")
9692
}
9793

98-
origins := w.GetRPOrigins()
99-
if len(origins) == 0 {
94+
if len(w.RPOrigins) == 0 {
10095
return nil, fmt.Errorf("webAuthn RP Origins cannot be empty")
10196
}
10297

10398
var validOrigins []string
10499
var invalidOrigins []string
105100

106-
for _, origin := range origins {
101+
for _, origin := range w.RPOrigins {
107102
parsedURL, err := url.Parse(origin)
108103
if err != nil || (parsedURL.Scheme != "https" && !(parsedURL.Scheme == "http" && parsedURL.Hostname() == "localhost")) || parsedURL.Host == "" {
109104
invalidOrigins = append(invalidOrigins, origin)
@@ -514,7 +509,18 @@ func (a *API) challengeWebAuthnFactor(w http.ResponseWriter, r *http.Request) er
514509
var ws *models.WebAuthnSessionData
515510
var challenge *models.Challenge
516511
if factor.IsUnverified() {
517-
options, session, err := webAuthn.BeginRegistration(user)
512+
// Get existing WebAuthn credentials to exclude duplicates
513+
excludeList := []wbnprotocol.CredentialDescriptor{}
514+
existingCredentials := user.WebAuthnCredentials()
515+
for _, cred := range existingCredentials {
516+
excludeList = append(excludeList, wbnprotocol.CredentialDescriptor{
517+
Type: wbnprotocol.PublicKeyCredentialType,
518+
CredentialID: cred.ID,
519+
Transport: []wbnprotocol.AuthenticatorTransport{"usb", "nfc"},
520+
})
521+
}
522+
523+
options, session, err := webAuthn.BeginRegistration(user, webauthn.WithExclusions(excludeList))
518524
if err != nil {
519525
return apierrors.NewInternalServerError("Failed to generate WebAuthn registration data").WithInternalError(err)
520526
}
@@ -524,9 +530,12 @@ func (a *API) challengeWebAuthnFactor(w http.ResponseWriter, r *http.Request) er
524530
challenge = ws.ToChallenge(factor.ID, ipAddress)
525531

526532
response = &ChallengeFactorResponse{
527-
CredentialCreationOptions: options,
528-
Type: factor.FactorType,
529-
ID: challenge.ID,
533+
Type: factor.FactorType,
534+
ID: challenge.ID,
535+
WebAuthn: &WebAuthnChallengeData{
536+
Type: "create",
537+
CredentialOptions: options,
538+
},
530539
}
531540

532541
} else if factor.IsVerified() {
@@ -539,9 +548,12 @@ func (a *API) challengeWebAuthnFactor(w http.ResponseWriter, r *http.Request) er
539548
}
540549
challenge = ws.ToChallenge(factor.ID, ipAddress)
541550
response = &ChallengeFactorResponse{
542-
CredentialRequestOptions: options,
543-
Type: factor.FactorType,
544-
ID: challenge.ID,
551+
Type: factor.FactorType,
552+
ID: challenge.ID,
553+
WebAuthn: &WebAuthnChallengeData{
554+
Type: "request",
555+
CredentialOptions: options,
556+
},
545557
}
546558

547559
}
@@ -878,10 +890,10 @@ func (a *API) verifyWebAuthnFactor(w http.ResponseWriter, r *http.Request, param
878890
switch {
879891
case params.WebAuthn == nil:
880892
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "WebAuthn config required")
881-
case factor.IsVerified() && params.WebAuthn.AssertionResponse == nil:
882-
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "creation_response required to login")
883-
case factor.IsUnverified() && params.WebAuthn.CreationResponse == nil:
884-
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "assertion_response required to login")
893+
case params.WebAuthn.Type != "create" && params.WebAuthn.Type != "request":
894+
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "WebAuthn type must be create or request")
895+
case params.WebAuthn.CredentialResponse == nil:
896+
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "credential_response required")
885897
default:
886898
webAuthn, err = params.WebAuthn.ToConfig()
887899
if err != nil {
@@ -899,20 +911,21 @@ func (a *API) verifyWebAuthnFactor(w http.ResponseWriter, r *http.Request, param
899911
return apierrors.NewInternalServerError("Database error deleting challenge").WithInternalError(err)
900912
}
901913

902-
if factor.IsUnverified() {
903-
parsedResponse, err := wbnprotocol.ParseCredentialCreationResponseBody(bytes.NewReader(params.WebAuthn.CreationResponse))
914+
switch params.WebAuthn.Type {
915+
case "create":
916+
parsedResponse, err := wbnprotocol.ParseCredentialCreationResponseBody(bytes.NewReader(params.WebAuthn.CredentialResponse))
904917
if err != nil {
905-
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid credential_creation_response")
918+
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid credential_response")
906919
}
907920
credential, err = webAuthn.CreateCredential(user, webAuthnSession, parsedResponse)
908921
if err != nil {
909922
return err
910923
}
911924

912-
} else if factor.IsVerified() {
913-
parsedResponse, err := wbnprotocol.ParseCredentialRequestResponseBody(bytes.NewReader(params.WebAuthn.AssertionResponse))
925+
case "request":
926+
parsedResponse, err := wbnprotocol.ParseCredentialRequestResponseBody(bytes.NewReader(params.WebAuthn.CredentialResponse))
914927
if err != nil {
915-
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid credential_request_response")
928+
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid credential_response")
916929
}
917930
credential, err = webAuthn.ValidateLogin(user, webAuthnSession, parsedResponse)
918931
if err != nil {

internal/api/mfa_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -718,7 +718,7 @@ func (ts *MFATestSuite) TestChallengeWebAuthnFactor() {
718718
factor := models.NewWebAuthnFactor(ts.TestUser, "WebAuthnfactor")
719719
validWebAuthnConfiguration := &WebAuthnParams{
720720
RPID: "localhost",
721-
RPOrigins: "http://localhost:3000",
721+
RPOrigins: []string{"http://localhost:3000"},
722722
}
723723
require.NoError(ts.T(), ts.API.db.Create(factor), "Error saving new test factor")
724724
token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)

0 commit comments

Comments
 (0)