diff --git a/internal/api/mfa.go b/internal/api/mfa.go index 16d0df3a6..81c80ab92 100644 --- a/internal/api/mfa.go +++ b/internal/api/mfa.go @@ -54,40 +54,36 @@ type EnrollFactorResponse struct { type ChallengeFactorParams struct { Channel string `json:"channel"` - WebAuthn *WebAuthnParams `json:"web_authn,omitempty"` + WebAuthn *WebAuthnParams `json:"webauthn,omitempty"` } type VerifyFactorParams struct { ChallengeID uuid.UUID `json:"challenge_id"` Code string `json:"code"` - WebAuthn *WebAuthnParams `json:"web_authn,omitempty"` + WebAuthn *WebAuthnParams `json:"webauthn,omitempty"` } type ChallengeFactorResponse struct { - ID uuid.UUID `json:"id"` - Type string `json:"type"` - ExpiresAt int64 `json:"expires_at,omitempty"` - CredentialRequestOptions *wbnprotocol.CredentialAssertion `json:"credential_request_options,omitempty"` - CredentialCreationOptions *wbnprotocol.CredentialCreation `json:"credential_creation_options,omitempty"` + ID uuid.UUID `json:"id"` + Type string `json:"type"` + ExpiresAt int64 `json:"expires_at,omitempty"` + WebAuthn *WebAuthnChallengeData `json:"webauthn,omitempty"` } -type UnenrollFactorResponse struct { - ID uuid.UUID `json:"id"` +type WebAuthnChallengeData struct { + Type string `json:"type"` // "create" or "request" + CredentialOptions interface{} `json:"credential_options"` } type WebAuthnParams struct { - RPID string `json:"rp_id,omitempty"` - // Can encode multiple origins as comma separated values like: "origin1,origin2" - RPOrigins string `json:"rp_origins,omitempty"` - AssertionResponse json.RawMessage `json:"assertion_response,omitempty"` - CreationResponse json.RawMessage `json:"creation_response,omitempty"` + RPID string `json:"rpId,omitempty"` + RPOrigins []string `json:"rpOrigins,omitempty"` + Type string `json:"type"` // "create" or "request" + CredentialResponse json.RawMessage `json:"credential_response"` } -func (w *WebAuthnParams) GetRPOrigins() []string { - if w.RPOrigins == "" { - return nil - } - return strings.Split(w.RPOrigins, ",") +type UnenrollFactorResponse struct { + ID uuid.UUID `json:"id"` } func (w *WebAuthnParams) ToConfig() (*webauthn.WebAuthn, error) { @@ -95,15 +91,14 @@ func (w *WebAuthnParams) ToConfig() (*webauthn.WebAuthn, error) { return nil, fmt.Errorf("webAuthn RP ID cannot be empty") } - origins := w.GetRPOrigins() - if len(origins) == 0 { + if len(w.RPOrigins) == 0 { return nil, fmt.Errorf("webAuthn RP Origins cannot be empty") } var validOrigins []string var invalidOrigins []string - for _, origin := range origins { + for _, origin := range w.RPOrigins { parsedURL, err := url.Parse(origin) if err != nil || (parsedURL.Scheme != "https" && !(parsedURL.Scheme == "http" && parsedURL.Hostname() == "localhost")) || parsedURL.Host == "" { invalidOrigins = append(invalidOrigins, origin) @@ -514,7 +509,18 @@ func (a *API) challengeWebAuthnFactor(w http.ResponseWriter, r *http.Request) er var ws *models.WebAuthnSessionData var challenge *models.Challenge if factor.IsUnverified() { - options, session, err := webAuthn.BeginRegistration(user) + // Get existing WebAuthn credentials to exclude duplicates + excludeList := []wbnprotocol.CredentialDescriptor{} + existingCredentials := user.WebAuthnCredentials() + for _, cred := range existingCredentials { + excludeList = append(excludeList, wbnprotocol.CredentialDescriptor{ + Type: wbnprotocol.PublicKeyCredentialType, + CredentialID: cred.ID, + Transport: []wbnprotocol.AuthenticatorTransport{"usb", "nfc"}, + }) + } + + options, session, err := webAuthn.BeginRegistration(user, webauthn.WithExclusions(excludeList)) if err != nil { return apierrors.NewInternalServerError("Failed to generate WebAuthn registration data").WithInternalError(err) } @@ -524,9 +530,12 @@ func (a *API) challengeWebAuthnFactor(w http.ResponseWriter, r *http.Request) er challenge = ws.ToChallenge(factor.ID, ipAddress) response = &ChallengeFactorResponse{ - CredentialCreationOptions: options, - Type: factor.FactorType, - ID: challenge.ID, + Type: factor.FactorType, + ID: challenge.ID, + WebAuthn: &WebAuthnChallengeData{ + Type: "create", + CredentialOptions: options, + }, } } else if factor.IsVerified() { @@ -539,9 +548,12 @@ func (a *API) challengeWebAuthnFactor(w http.ResponseWriter, r *http.Request) er } challenge = ws.ToChallenge(factor.ID, ipAddress) response = &ChallengeFactorResponse{ - CredentialRequestOptions: options, - Type: factor.FactorType, - ID: challenge.ID, + Type: factor.FactorType, + ID: challenge.ID, + WebAuthn: &WebAuthnChallengeData{ + Type: "request", + CredentialOptions: options, + }, } } @@ -878,10 +890,10 @@ func (a *API) verifyWebAuthnFactor(w http.ResponseWriter, r *http.Request, param switch { case params.WebAuthn == nil: return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "WebAuthn config required") - case factor.IsVerified() && params.WebAuthn.AssertionResponse == nil: - return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "creation_response required to login") - case factor.IsUnverified() && params.WebAuthn.CreationResponse == nil: - return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "assertion_response required to login") + case params.WebAuthn.Type != "create" && params.WebAuthn.Type != "request": + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "WebAuthn type must be create or request") + case params.WebAuthn.CredentialResponse == nil: + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "credential_response required") default: webAuthn, err = params.WebAuthn.ToConfig() if err != nil { @@ -899,20 +911,21 @@ func (a *API) verifyWebAuthnFactor(w http.ResponseWriter, r *http.Request, param return apierrors.NewInternalServerError("Database error deleting challenge").WithInternalError(err) } - if factor.IsUnverified() { - parsedResponse, err := wbnprotocol.ParseCredentialCreationResponseBody(bytes.NewReader(params.WebAuthn.CreationResponse)) + switch params.WebAuthn.Type { + case "create": + parsedResponse, err := wbnprotocol.ParseCredentialCreationResponseBody(bytes.NewReader(params.WebAuthn.CredentialResponse)) if err != nil { - return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid credential_creation_response") + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid credential_response") } credential, err = webAuthn.CreateCredential(user, webAuthnSession, parsedResponse) if err != nil { return err } - } else if factor.IsVerified() { - parsedResponse, err := wbnprotocol.ParseCredentialRequestResponseBody(bytes.NewReader(params.WebAuthn.AssertionResponse)) + case "request": + parsedResponse, err := wbnprotocol.ParseCredentialRequestResponseBody(bytes.NewReader(params.WebAuthn.CredentialResponse)) if err != nil { - return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid credential_request_response") + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid credential_response") } credential, err = webAuthn.ValidateLogin(user, webAuthnSession, parsedResponse) if err != nil { diff --git a/internal/api/mfa_test.go b/internal/api/mfa_test.go index 3778eca92..6daba2374 100644 --- a/internal/api/mfa_test.go +++ b/internal/api/mfa_test.go @@ -718,7 +718,7 @@ func (ts *MFATestSuite) TestChallengeWebAuthnFactor() { factor := models.NewWebAuthnFactor(ts.TestUser, "WebAuthnfactor") validWebAuthnConfiguration := &WebAuthnParams{ RPID: "localhost", - RPOrigins: "http://localhost:3000", + RPOrigins: []string{"http://localhost:3000"}, } require.NoError(ts.T(), ts.API.db.Create(factor), "Error saving new test factor") token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID) diff --git a/openapi.yaml b/openapi.yaml index 92e5d25be..83f940a20 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -932,6 +932,21 @@ paths: enum: - sms - whatsapp + webauthn: + type: object + required: + - rpId + - rpOrigins + properties: + rpId: + type: string + description: The relying party identifier (usually the domain) + rpOrigins: + type: array + items: + type: string + minItems: 1 + description: List of allowed origins for WebAuthn responses: 200: @@ -977,6 +992,30 @@ paths: format: uuid code: type: string + webauthn: + type: object + required: + - rpId + - rpOrigins + - type + - credential_response + properties: + rpId: + type: string + description: The relying party identifier + rpOrigins: + type: array + items: + type: string + minItems: 1 + description: List of allowed origins for WebAuthn + type: + type: string + enum: [create, request] + description: Type of WebAuthn operation + credential_response: + type: object + description: WebAuthn credential response from the client responses: 200: description: > @@ -2644,7 +2683,7 @@ components: - totp - phone - webauthn - web_authn_credential: + webauthn_credential: type: string phone: type: string @@ -2716,8 +2755,7 @@ components: required: - id - type - - expires_at - - credential_options + - webauthn properties: id: type: string @@ -2732,128 +2770,52 @@ components: type: integer example: 1674840917 description: UNIX seconds of the timestamp past which the challenge should not be verified. - credential_request_options: - $ref: '#/components/schemas/CredentialRequestOptions' - credential_creation_options: - $ref: '#/components/schemas/CredentialCreationOptions' + webauthn: + oneOf: + - type: object + required: + - type + - credential_options + properties: + type: + type: string + enum: [create] + description: Credential creation operation + credential_options: + type: object + required: + - publicKey + properties: + publicKey: + $ref: '#/components/schemas/CredentialCreationOptions' + - type: object + required: + - type + - credential_options + properties: + type: + type: string + enum: [request] + description: Credential request operation + credential_options: + type: object + required: + - publicKey + properties: + publicKey: + $ref: '#/components/schemas/CredentialRequestOptions' + discriminator: + propertyName: type - CredentialAssertion: + CredentialRequestOptions: type: object - description: WebAuthn credential assertion options + description: PublicKeyCredentialRequestOptions for WebAuthn authentication required: - challenge - - rpId - - allowCredentials - - timeout - properties: - challenge: - type: string - description: A random challenge generated by the server, base64url encoded - example: "Y2hhbGxlbmdlAyv-5P0kw1SG-OxhLbSHpRLdWaVR1w" - rpId: - type: string - description: The relying party's identifier (usually the domain name) - example: "example.com" - allowCredentials: - type: array - description: List of credentials acceptable for this authentication - items: - type: object - required: - - id - - type - properties: - id: - type: string - description: Credential ID, base64url encoded - example: "AXwyVxYT7BgNKwNq0YqUXaHHIdRK6OdFGCYgZF9K6zNu" - type: - type: string - enum: [public-key] - description: Type of the credential - timeout: - type: integer - description: Time (in milliseconds) that the user has to respond to the authentication prompt - example: 60000 - userVerification: - type: string - enum: [required, preferred, discouraged] - description: The relying party's requirements for user verification - default: preferred - extensions: - type: object - description: Additional parameters requesting additional processing by the client - status: - type: string - enum: [ok, failed] - description: Status of the credential assertion - errorMessage: - type: string - description: Error message if the assertion failed - userHandle: - type: string - description: User handle, base64url encoded - authenticatorAttachment: - type: string - enum: [platform, cross-platform] - description: Type of authenticator to use - - CredentialRequest: - type: object - description: WebAuthn credential request (for the response from the client) - required: - - id - - rawId - - type - - response - properties: - id: - type: string - description: Base64url encoding of the credential ID - example: "AXwyVxYT7BgNKwNq0YqUXaHHIdRK6OdFGCYgZF9K6zNu" - rawId: - type: string - description: Base64url encoding of the credential ID (same as id) - example: "AXwyVxYT7BgNKwNq0YqUXaHHIdRK6OdFGCYgZF9K6zNu" - type: - type: string - enum: [public-key] - description: Type of the credential - response: - type: object - required: - - clientDataJSON - - authenticatorData - - signature - - userHandle - properties: - clientDataJSON: - type: string - description: Base64url encoding of the client data - example: "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiY2hhbGxlbmdlIiwib3JpZ2luIjoiaHR0cHM6Ly9leGFtcGxlLmNvbSJ9" - authenticatorData: - type: string - description: Base64url encoding of the authenticator data - example: "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAXwyVxYT7BgNKwNq0YqUXaHHIdRK6OdFGCYgZF9K6zNu" - signature: - type: string - description: Base64url encoding of the signature - example: "MEUCIQCx5cJVAB3kGP6bqCIoAV6CkBpVAf8rcx0WSZ22fIxXvQIgCKFt9pEu1vK8U4JKYTfn6tGjvGNfx2F4uXrHSXlefvM" - userHandle: - type: string - description: Base64url encoding of the user handle - example: "MQ" - clientExtensionResults: - type: object - description: Client extension results - - CredentialRequestOptions: - type: object - description: Options for requesting an assertion properties: challenge: type: string - format: byte + format: base64url description: A challenge to be signed by the authenticator timeout: type: integer @@ -2863,46 +2825,88 @@ components: description: Relying Party ID allowCredentials: type: array + description: List of acceptable credentials for authentication items: $ref: '#/components/schemas/PublicKeyCredentialDescriptor' userVerification: type: string enum: [required, preferred, discouraged] description: User verification requirement + hints: + type: array + description: Hints for the browser about what types of authenticators to show + items: + type: string + enum: [security-key, client-device, hybrid] + extensions: + type: object + description: WebAuthn extensions to enable CredentialCreationOptions: type: object - description: Options for creating a new credential + description: PublicKeyCredentialCreationOptions for WebAuthn registration + required: + - rp + - user + - challenge + - pubKeyCredParams properties: rp: type: object + required: + - name + - id properties: id: type: string + description: Relying Party ID (usually the domain) name: type: string + description: Human-readable Relying Party name user: - $ref: '#/components/schemas/UserSchema' - + type: object + required: + - id + - name + - displayName + properties: + id: + description: User handle (opaque byte sequence, base64url encoded string or raw bytes) + oneOf: + - type: string + format: base64url + - type: string + name: + type: string + description: User account identifier (e.g., username or email) + displayName: + type: string + description: Human-readable user display name challenge: type: string - format: byte + format: base64url description: A challenge to be signed by the authenticator pubKeyCredParams: type: array + description: List of supported public key algorithms items: type: object + required: + - type + - alg properties: type: type: string enum: [public-key] alg: type: integer + description: COSE algorithm identifier (e.g., -7 for ES256, -257 for RS256) timeout: type: integer description: Time (in milliseconds) that the caller is willing to wait for the call to complete excludeCredentials: type: array + description: List of credentials to exclude (prevent re-registration) items: $ref: '#/components/schemas/PublicKeyCredentialDescriptor' authenticatorSelection: @@ -2911,31 +2915,57 @@ components: authenticatorAttachment: type: string enum: [platform, cross-platform] + description: Preferred authenticator type requireResidentKey: type: boolean + description: Whether to require a resident key (deprecated, use residentKey) + residentKey: + type: string + enum: [required, preferred, discouraged] + description: Resident key requirement userVerification: type: string enum: [required, preferred, discouraged] + description: User verification requirement attestation: type: string - enum: [none, indirect, direct] - description: Preferred attestation conveyance + enum: [none, indirect, direct, enterprise] + description: Attestation conveyance preference + attestationFormats: + type: array + description: Preferred attestation statement formats + items: + type: string + hints: + type: array + description: Hints for the browser about what types of authenticators to show + items: + type: string + enum: [security-key, client-device, hybrid] + extensions: + type: object + description: WebAuthn extensions to enable PublicKeyCredentialDescriptor: type: object + required: + - type + - id properties: type: type: string enum: [public-key] + description: Credential type id: type: string - format: byte + format: base64url description: Credential ID transports: type: array + description: Supported transports for this credential items: type: string - enum: [usb, nfc, ble, internal] + enum: [usb, nfc, ble, internal, hybrid] OAuthClientSchema: type: object