diff --git a/tests/integration/authn/passkey_auth_test.go b/tests/integration/authn/passkey_auth_test.go index d2decd4b66..d58574267d 100644 --- a/tests/integration/authn/passkey_auth_test.go +++ b/tests/integration/authn/passkey_auth_test.go @@ -12,8 +12,8 @@ import ( "net/http" "testing" - "github.com/thunder-id/thunderid/tests/integration/testutils" "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/tests/integration/testutils" ) const ( @@ -23,6 +23,9 @@ const ( passkeyAuthFinishEndpoint = "/auth/passkey/finish" testRelyingPartyID = "localhost" testRelyingPartyName = "ThunderID Test" + // testPasskeyOrigin must be one of the origins under passkey.allowed_origins in the test + // deployment.yaml, since the direct passkey APIs take their allowed origins from server config. + testPasskeyOrigin = "https://localhost:8095" ) var ( @@ -110,11 +113,13 @@ type PublicKeyCredential struct { Transports []string `json:"transports,omitempty"` } -// PasskeyRegisterFinishRequest represents the request to finish passkey registration +// PasskeyRegisterFinishRequest represents the request to finish passkey registration. +// Mirrors PasskeyRegisterFinishRequestDTO in backend/internal/authn/model.go. type PasskeyRegisterFinishRequest struct { PublicKeyCredential PublicKeyCredentialAttestation `json:"publicKeyCredential"` SessionToken string `json:"sessionToken"` - CredentialName string `json:"credentialName,omitempty"` + SkipAssertion bool `json:"skipAssertion,omitempty"` + Assertion string `json:"assertion,omitempty"` } // PublicKeyCredentialAttestation represents the attestation response @@ -132,12 +137,6 @@ type AuthenticatorAttestationResponse struct { Transports []string `json:"transports,omitempty"` } -// PasskeyRegisterFinishResponse represents the response from finishing passkey registration -type PasskeyRegisterFinishResponse struct { - CredentialID string `json:"credentialId"` - CredentialName string `json:"credentialName,omitempty"` -} - // PasskeyAuthStartRequest represents the request to start passkey authentication type PasskeyAuthStartRequest struct { UserID string `json:"userId"` @@ -159,14 +158,22 @@ type PublicKeyCredentialRequestOptionsResponse struct { UserVerification string `json:"userVerification,omitempty"` } -// PasskeyAuthFinishRequest represents the request to finish passkey authentication +// PasskeyAuthFinishRequest represents the request to finish passkey authentication. +// Mirrors PasskeyFinishRequestDTO in backend/internal/authn/model.go: the credential is nested +// under publicKeyCredential, exactly as it is for registration. type PasskeyAuthFinishRequest struct { - CredentialID string `json:"credentialId"` - CredentialType string `json:"credentialType"` - Response AuthenticatorAssertionResponse `json:"response"` - SessionToken string `json:"sessionToken"` - SkipAssertion bool `json:"skipAssertion,omitempty"` - ExistingAssertion string `json:"existingAssertion,omitempty"` + PublicKeyCredential PublicKeyCredentialAssertion `json:"publicKeyCredential"` + SessionToken string `json:"sessionToken"` + SkipAssertion bool `json:"skipAssertion,omitempty"` + Assertion string `json:"assertion,omitempty"` +} + +// PublicKeyCredentialAssertion represents a WebAuthn credential returned from an assertion ceremony +type PublicKeyCredentialAssertion struct { + ID string `json:"id"` + Type string `json:"type"` + RawID string `json:"rawId,omitempty"` + Response AuthenticatorAssertionResponse `json:"response"` } // AuthenticatorAssertionResponse represents the assertion response @@ -183,6 +190,17 @@ type PasskeyAuthTestSuite struct { testUserID string entityTypeID string ouID string + + // credentialUserID is a second user that owns a registered passkey. It is kept separate from + // testUserID so the tests asserting behaviour for a user with no credentials stay valid. + credentialUserID string + // authenticator holds a credential registered against credentialUserID during SetupSuite, so + // authentication tests do not have to register one and do not depend on test ordering. + authenticator *testutils.VirtualAuthenticator + sharedCredentialID string + // webAuthnUserHandle is the user.id the server issued for credentialUserID, needed as the + // userHandle in usernameless assertions. + webAuthnUserHandle string } func TestPasskeyAuthTestSuite(t *testing.T) { @@ -225,9 +243,82 @@ func (suite *PasskeyAuthTestSuite) SetupSuite() { userID, err := testutils.CreateUser(user) suite.Require().NoError(err, "Failed to create test user") suite.testUserID = userID + + // Create a second user to own a registered passkey. testUserID is deliberately left without + // credentials, since several tests assert the behaviour for a user that has none. + credentialAttributes, err := json.Marshal(map[string]interface{}{ + "username": "passkeytest_credential_user", + "email": "passkeytest_credential@example.com", + "displayName": "Passkey Credential User", + }) + suite.Require().NoError(err, "Failed to marshal credential user attributes") + + credentialUserID, err := testutils.CreateUser(testutils.User{ + Type: "passkey_user", + OUID: suite.ouID, + Attributes: json.RawMessage(credentialAttributes), + }) + suite.Require().NoError(err, "Failed to create credential test user") + suite.credentialUserID = credentialUserID + + // Register a credential the authentication tests can reuse. Doing it here rather than in a test + // keeps the authentication tests independent of execution order. + suite.authenticator, suite.webAuthnUserHandle = suite.registerCredential(suite.credentialUserID) + suite.sharedCredentialID = suite.authenticator.CredentialID() +} + +// registerCredential runs a full registration ceremony for the given user using a fresh virtual +// authenticator, and returns the authenticator along with the WebAuthn user handle the server +// issued for that user. Each authenticator owns a single credential ID, so callers that need a +// distinct credential must call this again rather than reusing an existing authenticator. +func (suite *PasskeyAuthTestSuite) registerCredential( + userID string, +) (*testutils.VirtualAuthenticator, string) { + authenticator, err := testutils.NewVirtualAuthenticator(testRelyingPartyID, testPasskeyOrigin) + suite.Require().NoError(err, "Failed to create virtual authenticator") + + startResponse, statusCode, err := suite.sendPasskeyRegisterStartRequest(PasskeyRegisterStartRequest{ + UserID: userID, + RelyingPartyID: testRelyingPartyID, + RelyingPartyName: testRelyingPartyName, + AuthenticatorSelection: &AuthenticatorSelectionCriteria{ + ResidentKey: "required", + UserVerification: "required", + }, + }) + suite.Require().NoError(err, "Failed to start passkey registration") + suite.Require().Equal(http.StatusOK, statusCode, "Expected status 200 for registration start") + + credentialID, clientDataJSON, attestationObject, err := authenticator.CreateAttestationResponse( + startResponse.PublicKeyCredentialCreationOptions.Challenge, true) + suite.Require().NoError(err, "Failed to build attestation response") + + _, statusCode, err = suite.sendPasskeyRegisterFinishRequest(PasskeyRegisterFinishRequest{ + PublicKeyCredential: PublicKeyCredentialAttestation{ + ID: credentialID, + Type: "public-key", + RawID: credentialID, + Response: AuthenticatorAttestationResponse{ + ClientDataJSON: clientDataJSON, + AttestationObject: attestationObject, + }, + }, + SessionToken: startResponse.SessionToken, + }) + suite.Require().NoError(err, "Failed to finish passkey registration") + suite.Require().Equal(http.StatusOK, statusCode, "Expected status 200 for registration finish") + + return authenticator, startResponse.PublicKeyCredentialCreationOptions.User.ID } func (suite *PasskeyAuthTestSuite) TearDownSuite() { + // Delete the user that owns the registered passkey + if suite.credentialUserID != "" { + if err := testutils.DeleteUser(suite.credentialUserID); err != nil { + suite.T().Errorf("Failed to delete credential test user during teardown: %v", err) + } + } + // Delete test user if suite.testUserID != "" { err := testutils.DeleteUser(suite.testUserID) @@ -393,8 +484,7 @@ func (suite *PasskeyAuthTestSuite) TestPasskeyRegistrationFinishInvalidSessionTo AttestationObject: base64.RawURLEncoding.EncodeToString([]byte("mock-attestation")), }, }, - SessionToken: "invalid-session-token", - CredentialName: "Test Credential", + SessionToken: "invalid-session-token", } _, statusCode, _ := suite.sendPasskeyRegisterFinishRequest(finishRequest) @@ -405,12 +495,14 @@ func (suite *PasskeyAuthTestSuite) TestPasskeyRegistrationFinishInvalidSessionTo // TestPasskeyAuthenticationFinishInvalidSessionToken tests finish authentication with invalid session func (suite *PasskeyAuthTestSuite) TestPasskeyAuthenticationFinishInvalidSessionToken() { finishRequest := PasskeyAuthFinishRequest{ - CredentialID: "mock-credential-id", - CredentialType: "public-key", - Response: AuthenticatorAssertionResponse{ - ClientDataJSON: base64.RawURLEncoding.EncodeToString([]byte(`{"type":"webauthn.get"}`)), - AuthenticatorData: base64.RawURLEncoding.EncodeToString([]byte("mock-auth-data")), - Signature: base64.RawURLEncoding.EncodeToString([]byte("mock-signature")), + PublicKeyCredential: PublicKeyCredentialAssertion{ + ID: "mock-credential-id", + Type: "public-key", + Response: AuthenticatorAssertionResponse{ + ClientDataJSON: base64.RawURLEncoding.EncodeToString([]byte(`{"type":"webauthn.get"}`)), + AuthenticatorData: base64.RawURLEncoding.EncodeToString([]byte("mock-auth-data")), + Signature: base64.RawURLEncoding.EncodeToString([]byte("mock-signature")), + }, }, SessionToken: "invalid-session-token", } @@ -517,16 +609,18 @@ func (suite *PasskeyAuthTestSuite) TestPasskeyAuthenticationFinishUsernamelessWi "RelyingPartyID should match") finishRequest := PasskeyAuthFinishRequest{ - CredentialID: "mock-credential-id", - CredentialType: "public-key", - Response: AuthenticatorAssertionResponse{ - ClientDataJSON: base64.RawURLEncoding.EncodeToString([]byte( - `{"type":"webauthn.get","challenge":"` + - authStartResponse.PublicKeyCredentialRequestOptions.Challenge + `","origin":"http://localhost"}`)), - AuthenticatorData: base64.RawURLEncoding.EncodeToString([]byte( - "mock-auth-data-with-sufficient-length-for-parsing")), - Signature: base64.RawURLEncoding.EncodeToString([]byte("mock-signature")), - UserHandle: base64.StdEncoding.EncodeToString([]byte(suite.testUserID)), + PublicKeyCredential: PublicKeyCredentialAssertion{ + ID: "mock-credential-id", + Type: "public-key", + Response: AuthenticatorAssertionResponse{ + ClientDataJSON: base64.RawURLEncoding.EncodeToString([]byte( + `{"type":"webauthn.get","challenge":"` + + authStartResponse.PublicKeyCredentialRequestOptions.Challenge + `","origin":"http://localhost"}`)), + AuthenticatorData: base64.RawURLEncoding.EncodeToString([]byte( + "mock-auth-data-with-sufficient-length-for-parsing")), + Signature: base64.RawURLEncoding.EncodeToString([]byte("mock-signature")), + UserHandle: base64.StdEncoding.EncodeToString([]byte(suite.testUserID)), + }, }, SessionToken: authStartResponse.SessionToken, } @@ -552,15 +646,17 @@ func (suite *PasskeyAuthTestSuite) TestPasskeyAuthenticationFinishUsernamelessWi // Attempt finish with invalid user handle finishRequest := PasskeyAuthFinishRequest{ - CredentialID: "mock-credential-id", - CredentialType: "public-key", - Response: AuthenticatorAssertionResponse{ - ClientDataJSON: base64.RawURLEncoding.EncodeToString([]byte( - `{"type":"webauthn.get","challenge":"` + - authStartResponse.PublicKeyCredentialRequestOptions.Challenge + `","origin":"http://localhost"}`)), - AuthenticatorData: base64.RawURLEncoding.EncodeToString([]byte("mock-auth-data")), - Signature: base64.RawURLEncoding.EncodeToString([]byte("mock-signature")), - UserHandle: "!!!invalid-base64!!!", + PublicKeyCredential: PublicKeyCredentialAssertion{ + ID: "mock-credential-id", + Type: "public-key", + Response: AuthenticatorAssertionResponse{ + ClientDataJSON: base64.RawURLEncoding.EncodeToString([]byte( + `{"type":"webauthn.get","challenge":"` + + authStartResponse.PublicKeyCredentialRequestOptions.Challenge + `","origin":"http://localhost"}`)), + AuthenticatorData: base64.RawURLEncoding.EncodeToString([]byte("mock-auth-data")), + Signature: base64.RawURLEncoding.EncodeToString([]byte("mock-signature")), + UserHandle: "!!!invalid-base64!!!", + }, }, SessionToken: authStartResponse.SessionToken, } @@ -585,13 +681,15 @@ func (suite *PasskeyAuthTestSuite) TestPasskeyAuthenticationFinishUsernamelessWi // Attempt finish without user handle finishRequest := PasskeyAuthFinishRequest{ - CredentialID: "mock-credential-id", - CredentialType: "public-key", - Response: AuthenticatorAssertionResponse{ - ClientDataJSON: base64.RawURLEncoding.EncodeToString([]byte(`{"type":"webauthn.get"}`)), - AuthenticatorData: base64.RawURLEncoding.EncodeToString([]byte("mock-auth-data")), - Signature: base64.RawURLEncoding.EncodeToString([]byte("mock-signature")), - UserHandle: "", // Empty userHandle + PublicKeyCredential: PublicKeyCredentialAssertion{ + ID: "mock-credential-id", + Type: "public-key", + Response: AuthenticatorAssertionResponse{ + ClientDataJSON: base64.RawURLEncoding.EncodeToString([]byte(`{"type":"webauthn.get"}`)), + AuthenticatorData: base64.RawURLEncoding.EncodeToString([]byte("mock-auth-data")), + Signature: base64.RawURLEncoding.EncodeToString([]byte("mock-signature")), + UserHandle: "", // Empty userHandle + }, }, SessionToken: authStartResponse.SessionToken, } @@ -617,15 +715,17 @@ func (suite *PasskeyAuthTestSuite) TestPasskeyAuthenticationFinishUsernamelessWi // Attempt finish with userHandle pointing to non-existent user nonExistentUserID := "non-existent-user-id-12345" finishRequest := PasskeyAuthFinishRequest{ - CredentialID: "mock-credential-id", - CredentialType: "public-key", - Response: AuthenticatorAssertionResponse{ - ClientDataJSON: base64.RawURLEncoding.EncodeToString([]byte( - `{"type":"webauthn.get","challenge":"` + - authStartResponse.PublicKeyCredentialRequestOptions.Challenge + `","origin":"http://localhost"}`)), - AuthenticatorData: base64.RawURLEncoding.EncodeToString([]byte("mock-auth-data")), - Signature: base64.RawURLEncoding.EncodeToString([]byte("mock-signature")), - UserHandle: base64.StdEncoding.EncodeToString([]byte(nonExistentUserID)), + PublicKeyCredential: PublicKeyCredentialAssertion{ + ID: "mock-credential-id", + Type: "public-key", + Response: AuthenticatorAssertionResponse{ + ClientDataJSON: base64.RawURLEncoding.EncodeToString([]byte( + `{"type":"webauthn.get","challenge":"` + + authStartResponse.PublicKeyCredentialRequestOptions.Challenge + `","origin":"http://localhost"}`)), + AuthenticatorData: base64.RawURLEncoding.EncodeToString([]byte("mock-auth-data")), + Signature: base64.RawURLEncoding.EncodeToString([]byte("mock-signature")), + UserHandle: base64.StdEncoding.EncodeToString([]byte(nonExistentUserID)), + }, }, SessionToken: authStartResponse.SessionToken, } @@ -650,16 +750,18 @@ func (suite *PasskeyAuthTestSuite) TestPasskeyAuthenticationUsernamelessValidati suite.Require().NotEmpty(authStartResponse.SessionToken, "Session token should not be empty") finishRequest := PasskeyAuthFinishRequest{ - CredentialID: base64.RawURLEncoding.EncodeToString([]byte("test-credential-id")), - CredentialType: "public-key", - Response: AuthenticatorAssertionResponse{ - ClientDataJSON: base64.RawURLEncoding.EncodeToString([]byte( - `{"type":"webauthn.get","challenge":"` + - authStartResponse.PublicKeyCredentialRequestOptions.Challenge + - `","origin":"http://` + testRelyingPartyID + `"}`)), - AuthenticatorData: base64.RawURLEncoding.EncodeToString(make([]byte, 37)), - Signature: base64.RawURLEncoding.EncodeToString([]byte("invalid-signature")), - UserHandle: base64.StdEncoding.EncodeToString([]byte(suite.testUserID)), + PublicKeyCredential: PublicKeyCredentialAssertion{ + ID: base64.RawURLEncoding.EncodeToString([]byte("test-credential-id")), + Type: "public-key", + Response: AuthenticatorAssertionResponse{ + ClientDataJSON: base64.RawURLEncoding.EncodeToString([]byte( + `{"type":"webauthn.get","challenge":"` + + authStartResponse.PublicKeyCredentialRequestOptions.Challenge + + `","origin":"http://` + testRelyingPartyID + `"}`)), + AuthenticatorData: base64.RawURLEncoding.EncodeToString(make([]byte, 37)), + Signature: base64.RawURLEncoding.EncodeToString([]byte("invalid-signature")), + UserHandle: base64.StdEncoding.EncodeToString([]byte(suite.testUserID)), + }, }, SessionToken: authStartResponse.SessionToken, } @@ -693,6 +795,252 @@ func (suite *PasskeyAuthTestSuite) TestPasskeyAuthenticationUsernameBasedValidat "Expected error when user has no registered credentials for username-based flow") } +// TestPasskeyRegisterFinishSuccess completes a registration ceremony with a credential that carries +// a real signature, and confirms the credential is persisted against the user. +func (suite *PasskeyAuthTestSuite) TestPasskeyRegisterFinishSuccess() { + // Register against a dedicated user so the credential does not affect other tests. + attributes, err := json.Marshal(map[string]interface{}{ + "username": "passkeytest_register_user", + "email": "passkeytest_register@example.com", + "displayName": "Passkey Register User", + }) + suite.Require().NoError(err, "Failed to marshal user attributes") + + userID, err := testutils.CreateUser(testutils.User{ + Type: "passkey_user", + OUID: suite.ouID, + Attributes: json.RawMessage(attributes), + }) + suite.Require().NoError(err, "Failed to create user for registration test") + defer func() { + if err := testutils.DeleteUser(userID); err != nil { + suite.T().Logf("Failed to delete registration test user: %v", err) + } + }() + + authenticator, _ := suite.registerCredential(userID) + + // Stored credentials are not exposed by any API, so persistence is confirmed by starting an + // authentication ceremony and checking the credential is offered back. + startResponse, statusCode, err := suite.sendPasskeyAuthStartRequest(PasskeyAuthStartRequest{ + UserID: userID, + RelyingPartyID: testRelyingPartyID, + }) + suite.Require().NoError(err, "Failed to start authentication after registration") + suite.Require().Equal(http.StatusOK, statusCode, "Expected status 200 for authentication start") + + credentialIDs := make([]string, 0, len(startResponse.PublicKeyCredentialRequestOptions.AllowCredentials)) + for _, credential := range startResponse.PublicKeyCredentialRequestOptions.AllowCredentials { + credentialIDs = append(credentialIDs, credential.ID) + } + suite.Contains(credentialIDs, authenticator.CredentialID(), + "Registered credential should be offered back in allowCredentials") +} + +// TestPasskeyAuthenticationUsernameBasedSuccess authenticates a user that has a registered +// credential, by supplying the user ID up front. +func (suite *PasskeyAuthTestSuite) TestPasskeyAuthenticationUsernameBasedSuccess() { + startResponse, statusCode, err := suite.sendPasskeyAuthStartRequest(PasskeyAuthStartRequest{ + UserID: suite.credentialUserID, + RelyingPartyID: testRelyingPartyID, + }) + suite.Require().NoError(err, "Failed to start passkey authentication") + suite.Require().Equal(http.StatusOK, statusCode, "Expected status 200 for authentication start") + suite.Require().NotEmpty(startResponse.PublicKeyCredentialRequestOptions.AllowCredentials, + "AllowCredentials should list the registered credential for a username-based ceremony") + + credentialID, clientDataJSON, authenticatorData, signature, err := + suite.authenticator.CreateAssertionResponse( + startResponse.PublicKeyCredentialRequestOptions.Challenge, true) + suite.Require().NoError(err, "Failed to build assertion response") + + response, statusCode, err := suite.sendPasskeyAuthFinishRequest(PasskeyAuthFinishRequest{ + PublicKeyCredential: PublicKeyCredentialAssertion{ + ID: credentialID, + Type: "public-key", + RawID: credentialID, + Response: AuthenticatorAssertionResponse{ + ClientDataJSON: clientDataJSON, + AuthenticatorData: authenticatorData, + Signature: signature, + UserHandle: suite.webAuthnUserHandle, + }, + }, + SessionToken: startResponse.SessionToken, + }) + suite.Require().NoError(err, "Failed to finish passkey authentication") + suite.Require().Equal(http.StatusOK, statusCode, "Expected status 200 for authentication finish") + suite.Equal(suite.credentialUserID, response.ID, "Response should identify the authenticated user") + suite.NotEmpty(response.Assertion, "A JWT assertion should be issued on success") +} + +// TestPasskeyAuthenticationUsernamelessSuccess authenticates without supplying a user ID, so the +// user is resolved from the credential's user handle through the discoverable credential path. +func (suite *PasskeyAuthTestSuite) TestPasskeyAuthenticationUsernamelessSuccess() { + startResponse, statusCode, err := suite.sendPasskeyAuthStartRequest(PasskeyAuthStartRequest{ + UserID: "", + RelyingPartyID: testRelyingPartyID, + }) + suite.Require().NoError(err, "Failed to start usernameless passkey authentication") + suite.Require().Equal(http.StatusOK, statusCode, "Expected status 200 for authentication start") + suite.Require().Empty(startResponse.PublicKeyCredentialRequestOptions.AllowCredentials, + "AllowCredentials should be empty for a usernameless ceremony") + + credentialID, clientDataJSON, authenticatorData, signature, err := + suite.authenticator.CreateAssertionResponse( + startResponse.PublicKeyCredentialRequestOptions.Challenge, true) + suite.Require().NoError(err, "Failed to build assertion response") + + response, statusCode, err := suite.sendPasskeyAuthFinishRequest(PasskeyAuthFinishRequest{ + PublicKeyCredential: PublicKeyCredentialAssertion{ + ID: credentialID, + Type: "public-key", + RawID: credentialID, + Response: AuthenticatorAssertionResponse{ + ClientDataJSON: clientDataJSON, + AuthenticatorData: authenticatorData, + Signature: signature, + UserHandle: suite.webAuthnUserHandle, + }, + }, + SessionToken: startResponse.SessionToken, + }) + suite.Require().NoError(err, "Failed to finish usernameless passkey authentication") + suite.Require().Equal(http.StatusOK, statusCode, "Expected status 200 for authentication finish") + suite.Equal(suite.credentialUserID, response.ID, + "Usernameless authentication should resolve the user from the user handle") + suite.NotEmpty(response.Assertion, "A JWT assertion should be issued on success") +} + +// TestPasskeyRegisterFinishWrongOrigin rejects a credential collected from an origin the server +// does not allow. +func (suite *PasskeyAuthTestSuite) TestPasskeyRegisterFinishWrongOrigin() { + statusCode := suite.attemptRegistrationWithAuthenticator(testRelyingPartyID, + "https://evil.example.com") + suite.Equal(http.StatusBadRequest, statusCode, + "Expected status 400 for a credential collected from a disallowed origin") +} + +// TestPasskeyRegisterFinishWrongRPID rejects a credential whose authenticator data was bound to a +// different relying party. +func (suite *PasskeyAuthTestSuite) TestPasskeyRegisterFinishWrongRPID() { + statusCode := suite.attemptRegistrationWithAuthenticator("example.com", testPasskeyOrigin) + suite.Equal(http.StatusBadRequest, statusCode, + "Expected status 400 for a credential bound to a different relying party") +} + +// TestPasskeyAuthenticationReplayedSessionToken confirms a session token cannot be used twice. +func (suite *PasskeyAuthTestSuite) TestPasskeyAuthenticationReplayedSessionToken() { + startResponse, statusCode, err := suite.sendPasskeyAuthStartRequest(PasskeyAuthStartRequest{ + UserID: suite.credentialUserID, + RelyingPartyID: testRelyingPartyID, + }) + suite.Require().NoError(err, "Failed to start passkey authentication") + suite.Require().Equal(http.StatusOK, statusCode, "Expected status 200 for authentication start") + + finishRequest := func() PasskeyAuthFinishRequest { + credentialID, clientDataJSON, authenticatorData, signature, buildErr := + suite.authenticator.CreateAssertionResponse( + startResponse.PublicKeyCredentialRequestOptions.Challenge, true) + suite.Require().NoError(buildErr, "Failed to build assertion response") + return PasskeyAuthFinishRequest{ + PublicKeyCredential: PublicKeyCredentialAssertion{ + ID: credentialID, + Type: "public-key", + RawID: credentialID, + Response: AuthenticatorAssertionResponse{ + ClientDataJSON: clientDataJSON, + AuthenticatorData: authenticatorData, + Signature: signature, + UserHandle: suite.webAuthnUserHandle, + }, + }, + SessionToken: startResponse.SessionToken, + } + } + + _, statusCode, err = suite.sendPasskeyAuthFinishRequest(finishRequest()) + suite.Require().NoError(err, "Failed to finish passkey authentication") + suite.Require().Equal(http.StatusOK, statusCode, "First use of the session token should succeed") + + _, statusCode, err = suite.sendPasskeyAuthFinishRequest(finishRequest()) + suite.Require().NoError(err, "Failed to send replayed authentication finish") + suite.True(statusCode == http.StatusBadRequest || statusCode == http.StatusUnauthorized, + "Replaying a consumed session token should be rejected, got %d", statusCode) +} + +// TestPasskeyAuthenticationSignCountRegression documents that a regressed signature counter is +// accepted. The library flags it through Authenticator.CloneWarning but returns no error, and that +// flag is never inspected, so cloned authenticators are not currently detected. +func (suite *PasskeyAuthTestSuite) TestPasskeyAuthenticationSignCountRegression() { + startResponse, statusCode, err := suite.sendPasskeyAuthStartRequest(PasskeyAuthStartRequest{ + UserID: suite.credentialUserID, + RelyingPartyID: testRelyingPartyID, + }) + suite.Require().NoError(err, "Failed to start passkey authentication") + suite.Require().Equal(http.StatusOK, statusCode, "Expected status 200 for authentication start") + + suite.authenticator.SetSignCount(0) + credentialID, clientDataJSON, authenticatorData, signature, err := + suite.authenticator.CreateAssertionResponse( + startResponse.PublicKeyCredentialRequestOptions.Challenge, true) + suite.Require().NoError(err, "Failed to build assertion response") + + _, statusCode, err = suite.sendPasskeyAuthFinishRequest(PasskeyAuthFinishRequest{ + PublicKeyCredential: PublicKeyCredentialAssertion{ + ID: credentialID, + Type: "public-key", + RawID: credentialID, + Response: AuthenticatorAssertionResponse{ + ClientDataJSON: clientDataJSON, + AuthenticatorData: authenticatorData, + Signature: signature, + UserHandle: suite.webAuthnUserHandle, + }, + }, + SessionToken: startResponse.SessionToken, + }) + suite.Require().NoError(err, "Failed to finish passkey authentication") + suite.Equal(http.StatusOK, statusCode, + "A regressed signature counter is currently accepted; update this test if clone detection is added") +} + +// attemptRegistrationWithAuthenticator runs a registration ceremony where the authenticator is +// built with the given relying party ID and origin, and returns the status of the finish call. The +// start call is expected to succeed, since these mismatches are only detectable at finish. +func (suite *PasskeyAuthTestSuite) attemptRegistrationWithAuthenticator(rpID, origin string) int { + authenticator, err := testutils.NewVirtualAuthenticator(rpID, origin) + suite.Require().NoError(err, "Failed to create virtual authenticator") + + startResponse, statusCode, err := suite.sendPasskeyRegisterStartRequest(PasskeyRegisterStartRequest{ + UserID: suite.testUserID, + RelyingPartyID: testRelyingPartyID, + RelyingPartyName: testRelyingPartyName, + }) + suite.Require().NoError(err, "Failed to start passkey registration") + suite.Require().Equal(http.StatusOK, statusCode, "Expected status 200 for registration start") + + credentialID, clientDataJSON, attestationObject, err := authenticator.CreateAttestationResponse( + startResponse.PublicKeyCredentialCreationOptions.Challenge, true) + suite.Require().NoError(err, "Failed to build attestation response") + + _, statusCode, err = suite.sendPasskeyRegisterFinishRequest(PasskeyRegisterFinishRequest{ + PublicKeyCredential: PublicKeyCredentialAttestation{ + ID: credentialID, + Type: "public-key", + RawID: credentialID, + Response: AuthenticatorAttestationResponse{ + ClientDataJSON: clientDataJSON, + AttestationObject: attestationObject, + }, + }, + SessionToken: startResponse.SessionToken, + }) + suite.Require().NoError(err, "Failed to finish passkey registration") + + return statusCode +} + // Helper methods func (suite *PasskeyAuthTestSuite) sendPasskeyRegisterStartRequest( @@ -736,7 +1084,7 @@ func (suite *PasskeyAuthTestSuite) sendPasskeyRegisterStartRequest( func (suite *PasskeyAuthTestSuite) sendPasskeyRegisterFinishRequest( request PasskeyRegisterFinishRequest, -) (*PasskeyRegisterFinishResponse, int, error) { +) (*testutils.AuthenticationResponse, int, error) { requestBody, err := json.Marshal(request) if err != nil { return nil, 0, fmt.Errorf("failed to marshal request: %w", err) @@ -765,7 +1113,7 @@ func (suite *PasskeyAuthTestSuite) sendPasskeyRegisterFinishRequest( return nil, resp.StatusCode, nil } - var response PasskeyRegisterFinishResponse + var response testutils.AuthenticationResponse if err := json.Unmarshal(body, &response); err != nil { return nil, resp.StatusCode, fmt.Errorf("failed to unmarshal response: %w", err) } diff --git a/tests/integration/flow/authentication/passkey_auth_flow_test.go b/tests/integration/flow/authentication/passkey_auth_flow_test.go new file mode 100644 index 0000000000..820706cd30 --- /dev/null +++ b/tests/integration/flow/authentication/passkey_auth_flow_test.go @@ -0,0 +1,437 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package authentication + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/tests/integration/flow/common" + "github.com/thunder-id/thunderid/tests/integration/testutils" +) + +const ( + passkeyFlowRelyingPartyID = "localhost" + passkeyFlowRelyingPartyName = "ThunderID Test" + // passkeyFlowOrigin is set as the application's only allowed passkey origin, which is what the + // flow executor validates the assertion against. It must also be listed under + // passkey.allowed_origins in the test deployment.yaml, since SetupSuite enrols the credential + // through the direct passkey API, and that path takes its allowed origins from server config. + passkeyFlowOrigin = "https://localhost:8095" +) + +// passkeyChallengeNode builds the challenge node, optionally omitting the relying party ID so the +// misconfiguration case can be exercised. +func passkeyChallengeNode(includeRelyingPartyID bool, nextNode string) map[string]interface{} { + node := map[string]interface{}{ + "id": "passkey_challenge", + "type": "TASK_EXECUTION", + "executor": map[string]interface{}{ + "name": "PasskeyAuthExecutor", + "mode": "challenge", + }, + "onSuccess": nextNode, + } + if includeRelyingPartyID { + node["properties"] = map[string]interface{}{ + "relyingPartyId": passkeyFlowRelyingPartyID, + "relyingPartyName": passkeyFlowRelyingPartyName, + } + } + return node +} + +// passkeyAssertionPromptNode builds the prompt that collects the assertion. When requireSignature is +// false the signature input is dropped, so the executor sees an incomplete submission. +func passkeyAssertionPromptNode(requireSignature bool) map[string]interface{} { + inputs := []map[string]interface{}{ + {"ref": "input_credential_id", "identifier": "credentialId", "type": "TEXT_INPUT", "required": true}, + {"ref": "input_client_data", "identifier": "clientDataJSON", "type": "TEXT_INPUT", "required": true}, + {"ref": "input_auth_data", "identifier": "authenticatorData", "type": "TEXT_INPUT", "required": true}, + } + if requireSignature { + inputs = append(inputs, map[string]interface{}{ + "ref": "input_signature", "identifier": "signature", "type": "TEXT_INPUT", "required": true, + }) + } + inputs = append(inputs, map[string]interface{}{ + "ref": "input_user_handle", "identifier": "userHandle", "type": "TEXT_INPUT", "required": false, + }) + + return map[string]interface{}{ + "id": "prompt_assertion", + "type": "PROMPT", + "prompts": []map[string]interface{}{ + { + "inputs": inputs, + "action": map[string]interface{}{ + "ref": "action_assertion", + "nextNode": "passkey_verify", + }, + }, + }, + } +} + +// passkeyTailNodes are the nodes shared by every variant once the assertion has been collected. +func passkeyTailNodes() []map[string]interface{} { + return []map[string]interface{}{ + { + "id": "passkey_verify", + "type": "TASK_EXECUTION", + "executor": map[string]interface{}{ + "name": "PasskeyAuthExecutor", + "mode": "verify", + }, + "onSuccess": "auth_assert", + }, + { + "id": "auth_assert", + "type": "TASK_EXECUTION", + "executor": map[string]interface{}{"name": "AuthAssertExecutor"}, + "onSuccess": "end", + }, + {"id": "end", "type": "END"}, + } +} + +// usernamePromptNodes prompt for a username and resolve it to a user before the challenge is +// generated, which is what makes the ceremony username based rather than usernameless. +func usernamePromptNodes() []map[string]interface{} { + return []map[string]interface{}{ + {"id": "start", "type": "START", "onSuccess": "prompt_username"}, + { + "id": "prompt_username", + "type": "PROMPT", + "prompts": []map[string]interface{}{ + { + "inputs": []map[string]interface{}{ + {"ref": "input_username", "identifier": "username", "type": "TEXT_INPUT", "required": true}, + }, + "action": map[string]interface{}{"ref": "action_username", "nextNode": "identify_user"}, + }, + }, + }, + { + "id": "identify_user", + "type": "TASK_EXECUTION", + "executor": map[string]interface{}{ + "name": "IdentifyingExecutor", + "mode": "identify", + "inputs": []map[string]interface{}{ + {"ref": "input_username", "identifier": "username", "type": "TEXT_INPUT", "required": true}, + }, + }, + "onSuccess": "passkey_challenge", + }, + } +} + +// buildUsernameFlow assembles a username based passkey authentication flow. +func buildUsernameFlow(name, handle string, includeRelyingPartyID, requireSignature bool) testutils.Flow { + nodes := usernamePromptNodes() + nodes = append(nodes, passkeyChallengeNode(includeRelyingPartyID, "prompt_assertion")) + nodes = append(nodes, passkeyAssertionPromptNode(requireSignature)) + nodes = append(nodes, passkeyTailNodes()...) + + return testutils.Flow{Name: name, FlowType: "AUTHENTICATION", Handle: handle, Nodes: nodes} +} + +// buildUsernamelessFlow assembles a flow that issues a challenge with no user in context, so the +// user is resolved from the credential's user handle at verification time. +func buildUsernamelessFlow(name, handle string) testutils.Flow { + nodes := []map[string]interface{}{ + {"id": "start", "type": "START", "onSuccess": "passkey_challenge"}, + passkeyChallengeNode(true, "prompt_assertion"), + passkeyAssertionPromptNode(true), + } + nodes = append(nodes, passkeyTailNodes()...) + + return testutils.Flow{Name: name, FlowType: "AUTHENTICATION", Handle: handle, Nodes: nodes} +} + +var ( + passkeyFlowTestOU = testutils.OrganizationUnit{ + Handle: "passkey-auth-flow-test-ou", + Name: "Passkey Auth Flow Test OU", + Description: "Organization unit for passkey authentication flow tests", + } + + passkeyFlowEntityType = testutils.UserType{ + Name: "passkey_flow_user", + Schema: map[string]interface{}{ + "username": map[string]interface{}{"type": "string"}, + "email": map[string]interface{}{"type": "string"}, + "displayName": map[string]interface{}{"type": "string"}, + }, + } + + passkeyFlowTestUser = testutils.User{ + Type: "passkey_flow_user", + Attributes: json.RawMessage(`{ + "username": "passkeyflowuser", + "email": "passkeyflowuser@example.com", + "displayName": "Passkey Flow User" + }`), + } + + passkeyFlowTestApp = testutils.Application{ + Name: "Passkey Auth Flow Test Application", + Description: "Application for testing passkey authentication flows", + IsRegistrationFlowEnabled: false, + ClientID: "passkey_auth_flow_test_client", + ClientSecret: "passkey_auth_flow_test_secret", + RedirectURIs: []string{"http://localhost:3000/callback"}, + AllowedUserTypes: []string{"passkey_flow_user"}, + // The flow executor takes its allowed origins from the application, not from server config. + PasskeyAllowedOrigins: []string{passkeyFlowOrigin}, + AssertionConfig: map[string]interface{}{ + "userAttributes": []string{"userType", "ouId", "ouName", "ouHandle"}, + }, + } +) + +type PasskeyAuthFlowTestSuite struct { + suite.Suite + config *common.TestSuiteConfig + + appID string + entityTypeID string + userID string + authenticator *testutils.VirtualAuthenticator + webAuthnUserHandle string + + baselineFlowID string + usernamelessFlowID string + missingSignatureID string +} + +func TestPasskeyAuthFlowTestSuite(t *testing.T) { + suite.Run(t, new(PasskeyAuthFlowTestSuite)) +} + +func (ts *PasskeyAuthFlowTestSuite) SetupSuite() { + ts.config = &common.TestSuiteConfig{} + + ouID, err := testutils.CreateOrganizationUnit(passkeyFlowTestOU) + ts.Require().NoError(err, "Failed to create test organization unit") + passkeyFlowTestOU.ID = ouID + + passkeyFlowEntityType.OUID = ouID + entityTypeID, err := testutils.CreateUserType(passkeyFlowEntityType) + ts.Require().NoError(err, "Failed to create test user type") + ts.entityTypeID = entityTypeID + + user := passkeyFlowTestUser + user.OUID = ouID + userIDs, err := testutils.CreateMultipleUsers(user) + ts.Require().NoError(err, "Failed to create test user") + ts.config.CreatedUserIDs = userIDs + ts.userID = userIDs[0] + + // Register a credential through the direct API. Flow tests exercise authentication, and + // registration through a flow is covered by the registration suite. + authenticator, userHandle, err := testutils.RegisterPasskeyCredential( + ts.userID, passkeyFlowRelyingPartyID, passkeyFlowRelyingPartyName, passkeyFlowOrigin) + ts.Require().NoError(err, "Failed to register a passkey credential for the test user") + ts.authenticator = authenticator + ts.webAuthnUserHandle = userHandle + + ts.baselineFlowID = ts.createFlow(buildUsernameFlow( + "Passkey Auth Flow Test", "auth_flow_passkey_test", true, true)) + ts.usernamelessFlowID = ts.createFlow(buildUsernamelessFlow( + "Passkey Usernameless Auth Flow Test", "auth_flow_passkey_usernameless_test")) + ts.missingSignatureID = ts.createFlow(buildUsernameFlow( + "Passkey Auth Flow Missing Signature Test", "auth_flow_passkey_no_signature_test", true, false)) + + passkeyFlowTestApp.OUID = ouID + passkeyFlowTestApp.AuthFlowID = ts.baselineFlowID + appID, err := testutils.CreateApplication(passkeyFlowTestApp) + ts.Require().NoError(err, "Failed to create test application") + ts.appID = appID +} + +func (ts *PasskeyAuthFlowTestSuite) createFlow(flow testutils.Flow) string { + flowID, err := testutils.CreateFlow(flow) + ts.Require().NoError(err, "Failed to create flow %s", flow.Handle) + ts.config.CreatedFlowIDs = append(ts.config.CreatedFlowIDs, flowID) + return flowID +} + +func (ts *PasskeyAuthFlowTestSuite) TearDownSuite() { + if err := testutils.CleanupUsers(ts.config.CreatedUserIDs); err != nil { + ts.T().Logf("Failed to cleanup users during teardown: %v", err) + } + + if ts.appID != "" { + if err := testutils.DeleteApplication(ts.appID); err != nil { + ts.T().Logf("Failed to delete application during teardown: %v", err) + } + } + + for _, flowID := range ts.config.CreatedFlowIDs { + if err := testutils.DeleteFlow(flowID); err != nil { + ts.T().Logf("Failed to delete flow %s during teardown: %v", flowID, err) + } + } + + if ts.entityTypeID != "" { + if err := testutils.DeleteUserType(ts.entityTypeID); err != nil { + ts.T().Logf("Failed to delete user type during teardown: %v", err) + } + } + + if passkeyFlowTestOU.ID != "" { + if err := testutils.DeleteOrganizationUnit(passkeyFlowTestOU.ID); err != nil { + ts.T().Logf("Failed to delete organization unit during teardown: %v", err) + } + } +} + +// useFlow points the test application at the given flow, so each test can drive its own variant. +func (ts *PasskeyAuthFlowTestSuite) useFlow(flowID string) { + ts.Require().NoError(common.UpdateAppConfig(ts.appID, flowID, ""), + "Failed to point the application at flow %s", flowID) +} + +// challengeFromStep decodes the credential request options the challenge node returns, which arrive +// as a JSON string in the step's additional data. +func (ts *PasskeyAuthFlowTestSuite) challengeFromStep(step *common.FlowStep) string { + raw, ok := step.Data.AdditionalData["passkeyChallenge"] + ts.Require().True(ok, "Flow step should carry a passkey challenge") + + var options struct { + Challenge string `json:"challenge"` + } + ts.Require().NoError(json.Unmarshal([]byte(raw), &options), + "Failed to decode passkey challenge options") + ts.Require().NotEmpty(options.Challenge, "Challenge should not be empty") + + return options.Challenge +} + +// assertionInputs builds the flow inputs for an assertion over the given challenge. +func (ts *PasskeyAuthFlowTestSuite) assertionInputs(challenge string) map[string]string { + credentialID, clientDataJSON, authenticatorData, signature, err := + ts.authenticator.CreateAssertionResponse(challenge, true) + ts.Require().NoError(err, "Failed to build assertion response") + + return map[string]string{ + "credentialId": credentialID, + "clientDataJSON": clientDataJSON, + "authenticatorData": authenticatorData, + "signature": signature, + "userHandle": ts.webAuthnUserHandle, + } +} + +// startUsernameFlow drives a username based flow up to the point where the assertion is requested. +func (ts *PasskeyAuthFlowTestSuite) startUsernameFlow() *common.FlowStep { + step, err := common.InitiateAuthenticationFlow(ts.appID, false, nil, "") + ts.Require().NoError(err, "Failed to initiate authentication flow") + ts.Require().Equal("INCOMPLETE", step.FlowStatus, "Expected flow status to be INCOMPLETE") + ts.Require().True(common.HasInput(step.Data.Inputs, "username"), "Username input should be required") + + step, err = common.CompleteFlow(step.ExecutionID, + map[string]string{"username": "passkeyflowuser"}, "action_username", step.ChallengeToken) + ts.Require().NoError(err, "Failed to submit username") + + return step +} + +// TestPasskeyAuthFlow_Success drives a full username based passkey ceremony through the flow API. +func (ts *PasskeyAuthFlowTestSuite) TestPasskeyAuthFlow_Success() { + ts.useFlow(ts.baselineFlowID) + + step := ts.startUsernameFlow() + ts.Require().Equal("INCOMPLETE", step.FlowStatus, "Expected flow status to be INCOMPLETE") + ts.Require().True(common.HasInput(step.Data.Inputs, "signature"), + "Assertion inputs should be requested after the challenge") + + challenge := ts.challengeFromStep(step) + + finalStep, err := common.CompleteFlow(step.ExecutionID, ts.assertionInputs(challenge), + "action_assertion", step.ChallengeToken) + ts.Require().NoError(err, "Failed to submit the passkey assertion") + ts.Require().Equal("COMPLETE", finalStep.FlowStatus, "Expected flow status to be COMPLETE") + ts.Require().Nil(finalStep.Error, "Error should be nil for a successful authentication") + ts.Require().NotEmpty(finalStep.Assertion, "A JWT assertion should be returned") + + claims, err := testutils.ValidateJWTAssertionFields(finalStep.Assertion, ts.appID, + passkeyFlowEntityType.Name, passkeyFlowTestOU.ID, passkeyFlowTestOU.Name, passkeyFlowTestOU.Handle) + ts.Require().NoError(err, "Failed to validate JWT assertion fields") + ts.Require().NotNil(claims, "JWT claims should not be nil") +} + +// TestPasskeyAuthFlow_Usernameless issues a challenge with no user in context, so the user is +// resolved from the credential itself. +func (ts *PasskeyAuthFlowTestSuite) TestPasskeyAuthFlow_Usernameless() { + ts.useFlow(ts.usernamelessFlowID) + + step, err := common.InitiateAuthenticationFlow(ts.appID, false, nil, "") + ts.Require().NoError(err, "Failed to initiate usernameless authentication flow") + ts.Require().Equal("INCOMPLETE", step.FlowStatus, "Expected flow status to be INCOMPLETE") + + challenge := ts.challengeFromStep(step) + + finalStep, err := common.CompleteFlow(step.ExecutionID, ts.assertionInputs(challenge), + "action_assertion", step.ChallengeToken) + ts.Require().NoError(err, "Failed to submit the passkey assertion") + ts.Require().Equal("COMPLETE", finalStep.FlowStatus, "Expected flow status to be COMPLETE") + ts.Require().NotEmpty(finalStep.Assertion, "A JWT assertion should be returned") +} + +// TestPasskeyAuthFlow_MissingRelyingPartyIdRejectedAtCreation covers a misconfigured challenge +// node. The executor carries a runtime guard for a missing relying party ID, but that guard is +// unreachable through the management API: relyingPartyId is declared required for the challenge +// mode, so the flow is rejected when it is created rather than when it runs. +func (ts *PasskeyAuthFlowTestSuite) TestPasskeyAuthFlow_MissingRelyingPartyIdRejectedAtCreation() { + _, err := testutils.CreateFlow(buildUsernameFlow( + "Passkey Auth Flow Without RP ID Test", "auth_flow_passkey_no_rp_test", false, true)) + ts.Require().Error(err, "A challenge node without a relying party ID should be rejected") + ts.Contains(err.Error(), "relyingPartyId", + "The rejection should name the missing executor property") +} + +// TestPasskeyAuthFlow_InvalidSignature rejects an assertion whose signature does not verify. +func (ts *PasskeyAuthFlowTestSuite) TestPasskeyAuthFlow_InvalidSignature() { + ts.useFlow(ts.baselineFlowID) + + step := ts.startUsernameFlow() + challenge := ts.challengeFromStep(step) + + inputs := ts.assertionInputs(challenge) + // Corrupt the signature while keeping it valid base64, so the request reaches signature + // verification rather than failing to parse. + inputs["signature"] = "AAAA" + inputs["signature"][4:] + + finalStep, err := common.CompleteFlow(step.ExecutionID, inputs, "action_assertion", + step.ChallengeToken) + if err == nil { + ts.Require().NotEqual("COMPLETE", finalStep.FlowStatus, + "A tampered signature must not complete the flow") + } +} + +// TestPasskeyAuthFlow_MissingRequiredInputs submits an assertion with no signature, which the +// executor should treat as an incomplete submission rather than a verification failure. +func (ts *PasskeyAuthFlowTestSuite) TestPasskeyAuthFlow_MissingRequiredInputs() { + ts.useFlow(ts.missingSignatureID) + + step := ts.startUsernameFlow() + challenge := ts.challengeFromStep(step) + + inputs := ts.assertionInputs(challenge) + delete(inputs, "signature") + + finalStep, err := common.CompleteFlow(step.ExecutionID, inputs, "action_assertion", + step.ChallengeToken) + if err == nil { + ts.Require().NotEqual("COMPLETE", finalStep.FlowStatus, + "An assertion without a signature must not complete the flow") + ts.Require().True(common.HasInput(finalStep.Data.Inputs, "signature"), + "The flow should ask for the missing signature input again") + } +} diff --git a/tests/integration/flow/registration/passkey_registration_test.go b/tests/integration/flow/registration/passkey_registration_test.go new file mode 100644 index 0000000000..115eef6e2c --- /dev/null +++ b/tests/integration/flow/registration/passkey_registration_test.go @@ -0,0 +1,437 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package registration + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/tests/integration/flow/common" + "github.com/thunder-id/thunderid/tests/integration/testutils" +) + +const ( + passkeyRegRelyingPartyID = "localhost" + passkeyRegRelyingPartyName = "ThunderID Test" + // passkeyRegOrigin is set as the application's only allowed passkey origin, which is what the + // flow executor validates the attestation against. It must also be listed under + // passkey.allowed_origins in the test deployment.yaml, since TestPasskeyRegistration_Success + // authenticates with the enrolled credential through the direct passkey API, and that path + // takes its allowed origins from server config. + passkeyRegOrigin = "https://localhost:8095" +) + +// buildPasskeyRegistrationFlow assembles a registration flow that provisions the user first, since +// the register_start mode needs a user in context, then enrols a passkey for them. +// +// registerStartProperties is merged into the register_start node, so tests can vary the relying +// party and authenticator configuration. +func buildPasskeyRegistrationFlow( + name, handle string, registerStartProperties map[string]interface{}, +) testutils.Flow { + return testutils.Flow{ + Name: name, + FlowType: "REGISTRATION", + Handle: handle, + Nodes: []map[string]interface{}{ + {"id": "start", "type": "START", "onSuccess": "user_type_resolver"}, + { + // Registration flows are required to carry a UserTypeResolver. + "id": "user_type_resolver", + "type": "TASK_EXECUTION", + "executor": map[string]interface{}{"name": "UserTypeResolver"}, + "onSuccess": "prompt_attributes", + "onIncomplete": "prompt_usertype", + }, + { + "id": "prompt_usertype", + "type": "PROMPT", + "prompts": []map[string]interface{}{ + { + "inputs": []map[string]interface{}{ + {"ref": "usertype_input", "identifier": "userType", "type": "SELECT", "required": true}, + }, + "action": map[string]interface{}{"ref": "action_usertype", "nextNode": "user_type_resolver"}, + }, + }, + }, + { + "id": "prompt_attributes", + "type": "PROMPT", + "prompts": []map[string]interface{}{ + { + "inputs": []map[string]interface{}{ + {"ref": "input_username", "identifier": "username", "type": "TEXT_INPUT", "required": true}, + {"ref": "input_email", "identifier": "email", "type": "TEXT_INPUT", "required": true}, + }, + "action": map[string]interface{}{"ref": "action_attributes", "nextNode": "provisioning"}, + }, + }, + }, + { + "id": "provisioning", + "type": "TASK_EXECUTION", + "executor": map[string]interface{}{"name": "ProvisioningExecutor"}, + "onSuccess": "passkey_register_start", + }, + { + "id": "passkey_register_start", + "type": "TASK_EXECUTION", + "properties": registerStartProperties, + "executor": map[string]interface{}{ + "name": "PasskeyAuthExecutor", + "mode": "register_start", + }, + "onSuccess": "prompt_attestation", + }, + { + "id": "prompt_attestation", + "type": "PROMPT", + "prompts": []map[string]interface{}{ + { + "inputs": []map[string]interface{}{ + {"ref": "input_credential_id", "identifier": "credentialId", "type": "TEXT_INPUT", "required": true}, + {"ref": "input_client_data", "identifier": "clientDataJSON", "type": "TEXT_INPUT", "required": true}, + {"ref": "input_attestation", "identifier": "attestationObject", "type": "TEXT_INPUT", "required": true}, + }, + "action": map[string]interface{}{"ref": "action_attestation", "nextNode": "passkey_register_finish"}, + }, + }, + }, + { + "id": "passkey_register_finish", + "type": "TASK_EXECUTION", + "executor": map[string]interface{}{ + "name": "PasskeyAuthExecutor", + "mode": "register_finish", + }, + "onSuccess": "auth_assert", + }, + { + "id": "auth_assert", + "type": "TASK_EXECUTION", + "executor": map[string]interface{}{"name": "AuthAssertExecutor"}, + "onSuccess": "end", + }, + {"id": "end", "type": "END"}, + }, + } +} + +var ( + passkeyRegTestOU = testutils.OrganizationUnit{ + Handle: "passkey-registration-test-ou", + Name: "Passkey Registration Test OU", + Description: "Organization unit for passkey registration flow tests", + } + + passkeyRegEntityType = testutils.UserType{ + Name: "passkey_reg_user", + AllowSelfRegistration: true, + Schema: map[string]interface{}{ + "username": map[string]interface{}{"type": "string"}, + "email": map[string]interface{}{"type": "string"}, + }, + } + + passkeyRegTestApp = testutils.Application{ + Name: "Passkey Registration Flow Test Application", + Description: "Application for testing passkey registration flows", + IsRegistrationFlowEnabled: true, + ClientID: "passkey_registration_flow_test_client", + ClientSecret: "passkey_registration_flow_test_secret", + RedirectURIs: []string{"http://localhost:3000/callback"}, + AllowedUserTypes: []string{"passkey_reg_user"}, + PasskeyAllowedOrigins: []string{passkeyRegOrigin}, + AssertionConfig: map[string]interface{}{ + "userAttributes": []string{"userType", "ouId", "ouName", "ouHandle"}, + }, + } +) + +type PasskeyRegistrationTestSuite struct { + suite.Suite + config *common.TestSuiteConfig + + appID string + entityTypeID string + + baselineFlowID string + authenticatorSelFlow string + attestationFlow string + defaultRPNameFlow string +} + +func TestPasskeyRegistrationTestSuite(t *testing.T) { + suite.Run(t, new(PasskeyRegistrationTestSuite)) +} + +func (ts *PasskeyRegistrationTestSuite) SetupSuite() { + ts.config = &common.TestSuiteConfig{} + + ouID, err := testutils.CreateOrganizationUnit(passkeyRegTestOU) + ts.Require().NoError(err, "Failed to create test organization unit") + passkeyRegTestOU.ID = ouID + + passkeyRegEntityType.OUID = ouID + entityTypeID, err := testutils.CreateUserType(passkeyRegEntityType) + ts.Require().NoError(err, "Failed to create test user type") + ts.entityTypeID = entityTypeID + + ts.baselineFlowID = ts.createFlow(buildPasskeyRegistrationFlow( + "Passkey Registration Flow Test", "reg_flow_passkey_test", + map[string]interface{}{ + "relyingPartyId": passkeyRegRelyingPartyID, + "relyingPartyName": passkeyRegRelyingPartyName, + })) + + ts.authenticatorSelFlow = ts.createFlow(buildPasskeyRegistrationFlow( + "Passkey Registration Authenticator Selection Test", "reg_flow_passkey_authsel_test", + map[string]interface{}{ + "relyingPartyId": passkeyRegRelyingPartyID, + "relyingPartyName": passkeyRegRelyingPartyName, + "authenticatorSelection": map[string]interface{}{ + "residentKey": "required", + "userVerification": "required", + }, + })) + + ts.attestationFlow = ts.createFlow(buildPasskeyRegistrationFlow( + "Passkey Registration Attestation Test", "reg_flow_passkey_attestation_test", + map[string]interface{}{ + "relyingPartyId": passkeyRegRelyingPartyID, + "relyingPartyName": passkeyRegRelyingPartyName, + "attestation": "direct", + })) + + // No relyingPartyName, so the relying party ID should be used in its place. + ts.defaultRPNameFlow = ts.createFlow(buildPasskeyRegistrationFlow( + "Passkey Registration Default RP Name Test", "reg_flow_passkey_default_rpname_test", + map[string]interface{}{"relyingPartyId": passkeyRegRelyingPartyID})) + + // An isolated auth flow avoids the cross-type reference check, which rejects an application + // whose authentication flow points at a different registration flow than the one configured. + isolatedAuthFlowID, err := testutils.CreateIsolatedAuthFlow("passkey-registration-isolated-auth") + ts.Require().NoError(err, "Failed to create isolated auth flow") + ts.config.CreatedFlowIDs = append(ts.config.CreatedFlowIDs, isolatedAuthFlowID) + + passkeyRegTestApp.OUID = ouID + passkeyRegTestApp.RegistrationFlowID = ts.baselineFlowID + passkeyRegTestApp.AuthFlowID = isolatedAuthFlowID + appID, err := testutils.CreateApplication(passkeyRegTestApp) + ts.Require().NoError(err, "Failed to create test application") + ts.appID = appID +} + +func (ts *PasskeyRegistrationTestSuite) createFlow(flow testutils.Flow) string { + flowID, err := testutils.CreateFlow(flow) + ts.Require().NoError(err, "Failed to create flow %s", flow.Handle) + ts.config.CreatedFlowIDs = append(ts.config.CreatedFlowIDs, flowID) + return flowID +} + +func (ts *PasskeyRegistrationTestSuite) TearDownSuite() { + if err := testutils.CleanupUsers(ts.config.CreatedUserIDs); err != nil { + ts.T().Logf("Failed to cleanup users during teardown: %v", err) + } + + if ts.appID != "" { + if err := testutils.DeleteApplication(ts.appID); err != nil { + ts.T().Logf("Failed to delete application during teardown: %v", err) + } + } + + for _, flowID := range ts.config.CreatedFlowIDs { + if err := testutils.DeleteFlow(flowID); err != nil { + ts.T().Logf("Failed to delete flow %s during teardown: %v", flowID, err) + } + } + + if ts.entityTypeID != "" { + if err := testutils.DeleteUserType(ts.entityTypeID); err != nil { + ts.T().Logf("Failed to delete user type during teardown: %v", err) + } + } + + if passkeyRegTestOU.ID != "" { + if err := testutils.DeleteOrganizationUnit(passkeyRegTestOU.ID); err != nil { + ts.T().Logf("Failed to delete organization unit during teardown: %v", err) + } + } +} + +// useFlow points the test application at the given registration flow. +func (ts *PasskeyRegistrationTestSuite) useFlow(flowID string) { + ts.Require().NoError(common.UpdateAppConfig(ts.appID, "", flowID), + "Failed to point the application at flow %s", flowID) +} + +// startRegistration provisions a user and returns the step that carries the creation options. +func (ts *PasskeyRegistrationTestSuite) startRegistration(username string) *common.FlowStep { + step, err := common.InitiateRegistrationFlow(ts.appID, false, nil, "") + ts.Require().NoError(err, "Failed to initiate registration flow") + ts.Require().Equal("INCOMPLETE", step.FlowStatus, "Expected flow status to be INCOMPLETE") + + step, err = common.CompleteFlow(step.ExecutionID, map[string]string{ + "username": username, + "email": username + "@example.com", + }, "action_attributes", step.ChallengeToken) + ts.Require().NoError(err, "Failed to submit registration attributes") + + return step +} + +// passkeyCreationOptions captures the fields of the credential creation options these tests assert +// on. It is the decoded form of the passkeyCreationOptions entry in a flow step's additional data. +type passkeyCreationOptions struct { + Challenge string `json:"challenge"` + RelyingParty struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"rp"` + User struct { + ID string `json:"id"` + } `json:"user"` + AuthenticatorSelection struct { + ResidentKey string `json:"residentKey"` + UserVerification string `json:"userVerification"` + } `json:"authenticatorSelection"` + Attestation string `json:"attestation"` +} + +// creationOptions decodes the credential creation options the register_start node returns. +func (ts *PasskeyRegistrationTestSuite) creationOptions(step *common.FlowStep) passkeyCreationOptions { + var options passkeyCreationOptions + + raw, ok := step.Data.AdditionalData["passkeyCreationOptions"] + ts.Require().True(ok, "Flow step should carry passkey creation options") + ts.Require().NoError(json.Unmarshal([]byte(raw), &options), + "Failed to decode passkey creation options") + ts.Require().NotEmpty(options.Challenge, "Challenge should not be empty") + + return options +} + +// completeRegistration enrols a credential for the challenge in the given step and returns the +// final flow step along with the authenticator that now holds the credential. +func (ts *PasskeyRegistrationTestSuite) completeRegistration( + step *common.FlowStep, challenge string, +) (*common.FlowStep, *testutils.VirtualAuthenticator) { + // A fresh authenticator per registration, since each one owns a single credential ID and every + // test in this suite provisions a new user. + authenticator, err := testutils.NewVirtualAuthenticator(passkeyRegRelyingPartyID, passkeyRegOrigin) + ts.Require().NoError(err, "Failed to create virtual authenticator") + + credentialID, clientDataJSON, attestationObject, err := authenticator.CreateAttestationResponse( + challenge, true) + ts.Require().NoError(err, "Failed to build attestation response") + + finalStep, err := common.CompleteFlow(step.ExecutionID, map[string]string{ + "credentialId": credentialID, + "clientDataJSON": clientDataJSON, + "attestationObject": attestationObject, + }, "action_attestation", step.ChallengeToken) + ts.Require().NoError(err, "Failed to submit the attestation") + + return finalStep, authenticator +} + +// TestPasskeyRegistration_Success registers a user and a passkey in one flow, then confirms the +// credential works by authenticating with it. Stored credentials are not exposed by any API, so +// using the credential is the only way to prove registration persisted it correctly. +func (ts *PasskeyRegistrationTestSuite) TestPasskeyRegistration_Success() { + ts.useFlow(ts.baselineFlowID) + + step := ts.startRegistration("passkeyreguser") + options := ts.creationOptions(step) + ts.Equal(passkeyRegRelyingPartyID, options.RelyingParty.ID, "Creation options should carry the relying party ID") + ts.Equal(passkeyRegRelyingPartyName, options.RelyingParty.Name, "Creation options should carry the relying party name") + + finalStep, authenticator := ts.completeRegistration(step, options.Challenge) + ts.Require().Equal("COMPLETE", finalStep.FlowStatus, "Expected flow status to be COMPLETE") + ts.Require().Nil(finalStep.Error, "Error should be nil for a successful registration") + ts.Require().NotEmpty(finalStep.Assertion, "A JWT assertion should be returned") + + claims, err := testutils.ValidateJWTAssertionFields(finalStep.Assertion, ts.appID, + passkeyRegEntityType.Name, passkeyRegTestOU.ID, passkeyRegTestOU.Name, passkeyRegTestOU.Handle) + ts.Require().NoError(err, "Failed to validate JWT assertion fields") + ts.Require().NotNil(claims, "JWT claims should not be nil") + + // Track the provisioned user so teardown removes it. + user, err := testutils.FindUserByAttribute("username", "passkeyreguser") + ts.Require().NoError(err, "Failed to look up the registered user") + ts.Require().NotNil(user, "The registration flow should have provisioned a user") + ts.config.CreatedUserIDs = append(ts.config.CreatedUserIDs, user.ID) + + // Authenticate with the credential just enrolled. Stored credentials are not readable through + // any API, so this is the only evidence that registration persisted a usable credential, and the + // only check that registration and authentication agree on the stored format. + authResponse, err := testutils.AuthenticateWithPasskey( + user.ID, passkeyRegRelyingPartyID, options.User.ID, authenticator) + ts.Require().NoError(err, "The credential enrolled during registration should authenticate") + ts.Equal(user.ID, authResponse.ID, "Authentication should identify the registered user") + ts.NotEmpty(authResponse.Assertion, "Authentication should issue an assertion") +} + +// TestPasskeyRegistration_AuthenticatorSelectionProperty checks the node level authenticator +// selection is passed through into the creation options the client receives. +func (ts *PasskeyRegistrationTestSuite) TestPasskeyRegistration_AuthenticatorSelectionProperty() { + ts.useFlow(ts.authenticatorSelFlow) + + step := ts.startRegistration("passkeyregauthsel") + options := ts.creationOptions(step) + + ts.Equal("required", options.AuthenticatorSelection.ResidentKey, + "Configured resident key requirement should reach the client") + ts.Equal("required", options.AuthenticatorSelection.UserVerification, + "Configured user verification requirement should reach the client") + + finalStep, _ := ts.completeRegistration(step, options.Challenge) + ts.Require().Equal("COMPLETE", finalStep.FlowStatus, "Expected flow status to be COMPLETE") + + user, err := testutils.FindUserByAttribute("username", "passkeyregauthsel") + ts.Require().NoError(err, "Failed to look up the registered user") + ts.Require().NotNil(user, "The registration flow should have provisioned a user") + ts.config.CreatedUserIDs = append(ts.config.CreatedUserIDs, user.ID) +} + +// TestPasskeyRegistration_AttestationProperty checks the node level attestation preference is +// passed through into the creation options. +func (ts *PasskeyRegistrationTestSuite) TestPasskeyRegistration_AttestationProperty() { + ts.useFlow(ts.attestationFlow) + + step := ts.startRegistration("passkeyregattest") + options := ts.creationOptions(step) + + ts.Equal("direct", options.Attestation, "Configured attestation preference should reach the client") + + finalStep, _ := ts.completeRegistration(step, options.Challenge) + ts.Require().Equal("COMPLETE", finalStep.FlowStatus, "Expected flow status to be COMPLETE") + + user, err := testutils.FindUserByAttribute("username", "passkeyregattest") + ts.Require().NoError(err, "Failed to look up the registered user") + ts.Require().NotNil(user, "The registration flow should have provisioned a user") + ts.config.CreatedUserIDs = append(ts.config.CreatedUserIDs, user.ID) +} + +// TestPasskeyRegistration_RelyingPartyNameDefault checks that omitting the relying party name falls +// back to the relying party ID. +func (ts *PasskeyRegistrationTestSuite) TestPasskeyRegistration_RelyingPartyNameDefault() { + ts.useFlow(ts.defaultRPNameFlow) + + step := ts.startRegistration("passkeyregdefaultrp") + options := ts.creationOptions(step) + + ts.Equal(passkeyRegRelyingPartyID, options.RelyingParty.Name, + "Relying party name should fall back to the relying party ID") + + finalStep, _ := ts.completeRegistration(step, options.Challenge) + ts.Require().Equal("COMPLETE", finalStep.FlowStatus, "Expected flow status to be COMPLETE") + + user, err := testutils.FindUserByAttribute("username", "passkeyregdefaultrp") + ts.Require().NoError(err, "Failed to look up the registered user") + ts.Require().NotNil(user, "The registration flow should have provisioned a user") + ts.config.CreatedUserIDs = append(ts.config.CreatedUserIDs, user.ID) +} diff --git a/tests/integration/resources/scripts/setup-test-config.sh b/tests/integration/resources/scripts/setup-test-config.sh index 3c40cf9d08..a81ea48c8d 100644 --- a/tests/integration/resources/scripts/setup-test-config.sh +++ b/tests/integration/resources/scripts/setup-test-config.sh @@ -121,6 +121,11 @@ flow: server_config: store: composite +passkey: + allowed_origins: + - "https://localhost:8095" + - "http://localhost:8095" + oauth: allow_wildcard_redirect_uri: true send_server_errors_to_client: true diff --git a/tests/integration/testutils/models.go b/tests/integration/testutils/models.go index 538686d3c5..fd59d7f6c4 100644 --- a/tests/integration/testutils/models.go +++ b/tests/integration/testutils/models.go @@ -43,6 +43,7 @@ type Application struct { AllowedUserTypes []string `json:"allowedUserTypes,omitempty"` SubjectAttribute map[string]string `json:"subjectAttribute,omitempty"` Certificate map[string]interface{} `json:"certificate,omitempty"` + PasskeyAllowedOrigins []string `json:"passkeyAllowedOrigins,omitempty"` InboundAuthConfig []map[string]interface{} `json:"inboundAuthConfig,omitempty"` AssertionConfig map[string]interface{} `json:"assertion,omitempty"` // LoginConsent is the login consent configuration (e.g. validityPeriod in seconds). diff --git a/tests/integration/testutils/passkey_utils.go b/tests/integration/testutils/passkey_utils.go new file mode 100644 index 0000000000..cad00e376d --- /dev/null +++ b/tests/integration/testutils/passkey_utils.go @@ -0,0 +1,218 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package testutils + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// passkeyRegisterStartRequest mirrors PasskeyRegisterStartRequestDTO in the backend. +type passkeyRegisterStartRequest struct { + UserID string `json:"userId"` + RelyingPartyID string `json:"relyingPartyId"` + RelyingPartyName string `json:"relyingPartyName,omitempty"` + AuthenticatorSelection *passkeyAuthenticatorSelection `json:"authenticatorSelection,omitempty"` +} + +type passkeyAuthenticatorSelection struct { + ResidentKey string `json:"residentKey,omitempty"` + UserVerification string `json:"userVerification,omitempty"` +} + +// passkeyRegisterStartResponse captures only the fields callers need from the start response. +type passkeyRegisterStartResponse struct { + SessionToken string `json:"sessionToken"` + PublicKeyCredentialCreationOptions struct { + Challenge string `json:"challenge"` + User struct { + ID string `json:"id"` + } `json:"user"` + } `json:"publicKeyCredentialCreationOptions"` +} + +// passkeyRegisterFinishRequest mirrors PasskeyRegisterFinishRequestDTO in the backend, where the +// credential is nested under publicKeyCredential. +type passkeyRegisterFinishRequest struct { + PublicKeyCredential passkeyAttestationCredential `json:"publicKeyCredential"` + SessionToken string `json:"sessionToken"` +} + +type passkeyAttestationCredential struct { + ID string `json:"id"` + Type string `json:"type"` + RawID string `json:"rawId"` + Response struct { + ClientDataJSON string `json:"clientDataJSON"` + AttestationObject string `json:"attestationObject"` + } `json:"response"` +} + +// RegisterPasskeyCredential registers a passkey for the given user through the direct passkey API +// and returns the virtual authenticator holding it, along with the WebAuthn user handle the server +// issued for that user. Flow based suites use this to obtain a credential without having to drive a +// registration flow. +// +// The origin must be accepted by the server level passkey.allowed_origins, since the direct API +// does not take allowed origins from the request or the application. +func RegisterPasskeyCredential(userID, relyingPartyID, relyingPartyName, origin string) ( + *VirtualAuthenticator, string, error) { + authenticator, err := NewVirtualAuthenticator(relyingPartyID, origin) + if err != nil { + return nil, "", err + } + + startBody, err := passkeyPost("/register/passkey/start", passkeyRegisterStartRequest{ + UserID: userID, + RelyingPartyID: relyingPartyID, + RelyingPartyName: relyingPartyName, + AuthenticatorSelection: &passkeyAuthenticatorSelection{ + ResidentKey: "required", + UserVerification: "required", + }, + }) + if err != nil { + return nil, "", fmt.Errorf("passkey registration start failed: %w", err) + } + + var startResponse passkeyRegisterStartResponse + if err := json.Unmarshal(startBody, &startResponse); err != nil { + return nil, "", fmt.Errorf("failed to decode passkey registration start response: %w", err) + } + + credentialID, clientDataJSON, attestationObject, err := authenticator.CreateAttestationResponse( + startResponse.PublicKeyCredentialCreationOptions.Challenge, true) + if err != nil { + return nil, "", err + } + + credential := passkeyAttestationCredential{ID: credentialID, Type: "public-key", RawID: credentialID} + credential.Response.ClientDataJSON = clientDataJSON + credential.Response.AttestationObject = attestationObject + + if _, err := passkeyPost("/register/passkey/finish", passkeyRegisterFinishRequest{ + PublicKeyCredential: credential, + SessionToken: startResponse.SessionToken, + }); err != nil { + return nil, "", fmt.Errorf("passkey registration finish failed: %w", err) + } + + return authenticator, startResponse.PublicKeyCredentialCreationOptions.User.ID, nil +} + +// passkeyAuthStartRequest mirrors PasskeyStartRequestDTO in the backend. +type passkeyAuthStartRequest struct { + UserID string `json:"userId"` + RelyingPartyID string `json:"relyingPartyId"` +} + +type passkeyAuthStartResponse struct { + SessionToken string `json:"sessionToken"` + PublicKeyCredentialRequestOptions struct { + Challenge string `json:"challenge"` + } `json:"publicKeyCredentialRequestOptions"` +} + +// passkeyAuthFinishRequest mirrors PasskeyFinishRequestDTO in the backend, where the credential is +// nested under publicKeyCredential just as it is for registration. +type passkeyAuthFinishRequest struct { + PublicKeyCredential passkeyAssertionCredential `json:"publicKeyCredential"` + SessionToken string `json:"sessionToken"` +} + +type passkeyAssertionCredential struct { + ID string `json:"id"` + Type string `json:"type"` + RawID string `json:"rawId"` + Response struct { + ClientDataJSON string `json:"clientDataJSON"` + AuthenticatorData string `json:"authenticatorData"` + Signature string `json:"signature"` + UserHandle string `json:"userHandle,omitempty"` + } `json:"response"` +} + +// AuthenticateWithPasskey runs a full authentication ceremony for the given user with the supplied +// authenticator, and returns the authentication response. Registration suites use it to prove a +// credential they just enrolled is actually usable, which is the only externally visible evidence +// that the credential was stored correctly. +func AuthenticateWithPasskey( + userID, relyingPartyID, userHandle string, authenticator *VirtualAuthenticator, +) (*AuthenticationResponse, error) { + startBody, err := passkeyPost("/auth/passkey/start", passkeyAuthStartRequest{ + UserID: userID, + RelyingPartyID: relyingPartyID, + }) + if err != nil { + return nil, fmt.Errorf("passkey authentication start failed: %w", err) + } + + var startResponse passkeyAuthStartResponse + if err := json.Unmarshal(startBody, &startResponse); err != nil { + return nil, fmt.Errorf("failed to decode passkey authentication start response: %w", err) + } + + credentialID, clientDataJSON, authenticatorData, signature, err := + authenticator.CreateAssertionResponse( + startResponse.PublicKeyCredentialRequestOptions.Challenge, true) + if err != nil { + return nil, err + } + + credential := passkeyAssertionCredential{ID: credentialID, Type: "public-key", RawID: credentialID} + credential.Response.ClientDataJSON = clientDataJSON + credential.Response.AuthenticatorData = authenticatorData + credential.Response.Signature = signature + credential.Response.UserHandle = userHandle + + finishBody, err := passkeyPost("/auth/passkey/finish", passkeyAuthFinishRequest{ + PublicKeyCredential: credential, + SessionToken: startResponse.SessionToken, + }) + if err != nil { + return nil, fmt.Errorf("passkey authentication finish failed: %w", err) + } + + var response AuthenticationResponse + if err := json.Unmarshal(finishBody, &response); err != nil { + return nil, fmt.Errorf("failed to decode passkey authentication response: %w", err) + } + + return &response, nil +} + +// passkeyPost posts a JSON body to a passkey endpoint and returns the response body, treating any +// non-200 status as an error. +func passkeyPost(path string, payload interface{}) ([]byte, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, TestServerURL+path, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := GetHTTPClient().Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d from %s: %s", resp.StatusCode, path, responseBody) + } + + return responseBody, nil +} diff --git a/tests/integration/testutils/webauthn_authenticator.go b/tests/integration/testutils/webauthn_authenticator.go new file mode 100644 index 0000000000..0a549bd025 --- /dev/null +++ b/tests/integration/testutils/webauthn_authenticator.go @@ -0,0 +1,225 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package testutils + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" +) + +// VirtualAuthenticator is a software WebAuthn authenticator for integration tests. It holds an +// ES256 key pair and produces the byte structures a browser would return from a real authenticator, +// so tests can complete passkey registration and authentication ceremonies end to end. +// +// One instance owns exactly one credential ID. A test that needs a second distinct credential must +// construct a second instance. +type VirtualAuthenticator struct { + key *ecdsa.PrivateKey + aaguid [16]byte + credID []byte + signCount uint32 + rpID string + origin string +} + +// WebAuthn authenticator data flags. +const ( + flagUserPresent = 0x01 + flagUserVerified = 0x04 + flagBackupEligible = 0x08 + flagBackupState = 0x10 + flagAttestedCredentialData = 0x40 +) + +// NewVirtualAuthenticator creates an authenticator bound to a relying party ID and origin. The +// origin must be one the server accepts: for the direct passkey APIs that is an entry under +// passkey.allowed_origins in deployment.yaml, and for flow-based passkey it is an entry in the +// application's passkeyAllowedOrigins. +func NewVirtualAuthenticator(rpID, origin string) (*VirtualAuthenticator, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("failed to generate authenticator key: %w", err) + } + + credID := make([]byte, 32) + if _, err := rand.Read(credID); err != nil { + return nil, fmt.Errorf("failed to generate credential ID: %w", err) + } + + return &VirtualAuthenticator{ + key: key, + credID: credID, + rpID: rpID, + origin: origin, + }, nil +} + +// CredentialID returns the base64url encoded credential ID held by this authenticator. +func (a *VirtualAuthenticator) CredentialID() string { + return base64.RawURLEncoding.EncodeToString(a.credID) +} + +// SetSignCount overrides the signature counter, so tests can simulate a counter regression. +func (a *VirtualAuthenticator) SetSignCount(count uint32) { + a.signCount = count +} + +// CreateAttestationResponse produces the credential a browser returns from navigator.credentials +// .create(), for the registration ceremony. The challenge must be the value from the corresponding +// start response, passed through unchanged. All returned values are base64url encoded. +func (a *VirtualAuthenticator) CreateAttestationResponse(challenge string, userVerified bool) ( + credentialID, clientDataJSON, attestationObject string, err error) { + clientData := a.buildClientData("webauthn.create", challenge) + + flags := byte(flagUserPresent | flagBackupEligible | flagBackupState | flagAttestedCredentialData) + if userVerified { + flags |= flagUserVerified + } + authData := a.buildAuthenticatorData(flags, true) + + attestation := cborMap3( + cborTextString("fmt"), cborTextString("none"), + cborTextString("attStmt"), []byte{0xA0}, + cborTextString("authData"), cborByteString(authData), + ) + + return a.CredentialID(), + base64.RawURLEncoding.EncodeToString(clientData), + base64.RawURLEncoding.EncodeToString(attestation), + nil +} + +// CreateAssertionResponse produces the credential a browser returns from navigator.credentials.get(), +// for the authentication ceremony. The challenge must be the value from the corresponding start +// response, passed through unchanged. All returned values are base64url encoded. +func (a *VirtualAuthenticator) CreateAssertionResponse(challenge string, userVerified bool) ( + credentialID, clientDataJSON, authenticatorData, signature string, err error) { + clientData := a.buildClientData("webauthn.get", challenge) + + flags := byte(flagUserPresent | flagBackupEligible | flagBackupState) + if userVerified { + flags |= flagUserVerified + } + a.signCount++ + authData := a.buildAuthenticatorData(flags, false) + + // The assertion is signed over the authenticator data concatenated with the hash of the client + // data, per the WebAuthn assertion signature format. + clientDataHash := sha256.Sum256(clientData) + signed := append(append([]byte{}, authData...), clientDataHash[:]...) + digest := sha256.Sum256(signed) + + sig, err := ecdsa.SignASN1(rand.Reader, a.key, digest[:]) + if err != nil { + return "", "", "", "", fmt.Errorf("failed to sign assertion: %w", err) + } + + return a.CredentialID(), + base64.RawURLEncoding.EncodeToString(clientData), + base64.RawURLEncoding.EncodeToString(authData), + base64.RawURLEncoding.EncodeToString(sig), + nil +} + +// buildClientData assembles the collected client data a browser sends. The challenge is echoed +// exactly as received, since the server compares it against the base64url value it issued. +func (a *VirtualAuthenticator) buildClientData(ceremonyType, challenge string) []byte { + clientData := map[string]interface{}{ + "type": ceremonyType, + "challenge": challenge, + "origin": a.origin, + "crossOrigin": false, + } + encoded, err := json.Marshal(clientData) + if err != nil { + // The map contains only strings and a bool, so marshalling cannot fail. + panic(fmt.Sprintf("failed to marshal client data: %v", err)) + } + return encoded +} + +// buildAuthenticatorData assembles the authenticator data structure. When includeCredential is set +// the attested credential data (AAGUID, credential ID and COSE public key) is appended, which is +// required for registration and absent for authentication. +func (a *VirtualAuthenticator) buildAuthenticatorData(flags byte, includeCredential bool) []byte { + rpIDHash := sha256.Sum256([]byte(a.rpID)) + + authData := make([]byte, 0, 37) + authData = append(authData, rpIDHash[:]...) + authData = append(authData, flags) + authData = binary.BigEndian.AppendUint32(authData, a.signCount) + + if !includeCredential { + return authData + } + + authData = append(authData, a.aaguid[:]...) + authData = binary.BigEndian.AppendUint16(authData, uint16(len(a.credID))) + authData = append(authData, a.credID...) + authData = append(authData, a.coseKey()...) + + return authData +} + +// coseKey encodes the public key as a COSE_Key structure for an ES256 key on the P-256 curve. The +// map is written directly rather than through a CBOR library, since its shape is fixed: the test +// module depends only on testify, and this keeps the encoded bytes inspectable when a verification +// failure needs debugging. +func (a *VirtualAuthenticator) coseKey() []byte { + x := make([]byte, 32) + y := make([]byte, 32) + a.key.PublicKey.X.FillBytes(x) + a.key.PublicKey.Y.FillBytes(y) + + key := []byte{ + 0xA5, // map with 5 pairs + 0x01, 0x02, // kty: EC2 + 0x03, 0x26, // alg: -7 (ES256) + 0x20, 0x01, // crv: 1 (P-256) + 0x21, 0x58, 0x20, // x: byte string of 32 + } + key = append(key, x...) + key = append(key, 0x22, 0x58, 0x20) // y: byte string of 32 + key = append(key, y...) + + return key +} + +// cborTextString encodes a short text string. Only lengths below 24 occur here, so the length is +// packed into the initial byte. +func cborTextString(s string) []byte { + if len(s) >= 24 { + panic(fmt.Sprintf("cborTextString only supports strings shorter than 24 bytes, got %d", len(s))) + } + return append([]byte{0x60 | byte(len(s))}, []byte(s)...) +} + +// cborByteString encodes a byte string with the smallest length header that fits. +func cborByteString(b []byte) []byte { + var header []byte + switch { + case len(b) < 24: + header = []byte{0x40 | byte(len(b))} + case len(b) < 256: + header = []byte{0x58, byte(len(b))} + default: + header = binary.BigEndian.AppendUint16([]byte{0x59}, uint16(len(b))) + } + return append(header, b...) +} + +// cborMap3 assembles a CBOR map of exactly three already encoded key and value pairs. +func cborMap3(k1, v1, k2, v2, k3, v3 []byte) []byte { + out := []byte{0xA3} + for _, part := range [][]byte{k1, v1, k2, v2, k3, v3} { + out = append(out, part...) + } + return out +}