package webauthn import ( "bytes" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "github.com/go-webauthn/webauthn/metadata" "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/testing/mocks" ) func TestLogin_FinishLoginFailure(t *testing.T) { user := &defaultUser{ id: []byte("123"), } session := SessionData{ UserID: []byte("ABC"), } webauthn := &WebAuthn{} credential, err := webauthn.FinishLogin(user, session, nil) if err == nil { t.Errorf("FinishLogin() error = nil, want %v", protocol.ErrBadRequest.Type) } if credential != nil { t.Errorf("FinishLogin() credential = %v, want nil", credential) } } func TestWithLoginRelyingPartyID(t *testing.T) { testCases := []struct { name string have *Config opts []LoginOption expectedID string expectedChallenge []byte err string }{ { name: "OptionDefinedInConfig", have: &Config{ RPID: "example.com", RPDisplayName: "Test Display Name", RPOrigins: []string{"https://example.com"}, }, opts: nil, expectedID: "example.com", }, { name: "OptionDefinedInConfigAndOpts", have: &Config{ RPID: "example.com", RPDisplayName: "Test Display Name", RPOrigins: []string{"https://example.com"}, }, opts: []LoginOption{WithLoginRelyingPartyID("a.example.com")}, expectedID: "a.example.com", }, { name: "OptionDefinedInConfigWithNoErrAndInOptsWithError", have: &Config{ RPID: "example.com", RPDisplayName: "Test Display Name", RPOrigins: []string{"https://example.com"}, }, opts: []LoginOption{WithLoginRelyingPartyID("---::~!!~@#M!@OIK#N!@IOK@@@@@@@@@@")}, err: "error generating assertion: the relying party id failed to validate as it's not a valid domain string with error: parse \"---::~!!~@\": first path segment in URL cannot contain colon", }, { name: "OptionDefinedInOpts", have: &Config{ RPOrigins: []string{"https://example.com"}, }, opts: []LoginOption{WithLoginRelyingPartyID("example.com")}, expectedID: "example.com", }, { name: "OptionIDNotDefined", have: &Config{ RPOrigins: []string{"https://example.com"}, }, opts: nil, err: "error generating assertion: the relying party id must be provided via the configuration or a functional option for a login", }, { name: "TooShortWithChallengeOption", have: &Config{ RPID: "example.com", RPOrigins: []string{"https://example.com"}, RPDisplayName: "Test Display Name", }, opts: []LoginOption{WithChallenge([]byte("1234567890"))}, err: "error generating assertion: the challenge must be at least 16 bytes", }, { name: "WithChallengeOption", have: &Config{ RPID: "example.com", RPOrigins: []string{"https://example.com"}, RPDisplayName: "Test Display Name", }, opts: []LoginOption{WithChallenge([]byte("00000000000000000000000000000000"))}, expectedID: "example.com", expectedChallenge: []byte("00000000000000000000000000000000"), }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { w, err := New(tc.have) assert.NoError(t, err) user := &defaultUser{ credentials: []Credential{ {}, }, } creation, _, err := w.BeginLogin(user, tc.opts...) if tc.err != "" { assert.EqualError(t, err, tc.err) } else { assert.NoError(t, err) require.NotNil(t, creation) assert.Equal(t, tc.expectedID, creation.Response.RelyingPartyID) if len(tc.expectedChallenge) > 0 { assert.Equal(t, protocol.URLEncodedBase64(tc.expectedChallenge).String(), creation.Response.Challenge.String()) } } }) } } func TestFinishLoginFailure(t *testing.T) { const ( credentialID = "AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng" //nolint:gosec userHandle = "0ToAAAAAAAAAAA" ) var ( byteUserHandle, _ = base64.RawURLEncoding.DecodeString(userHandle) byteID, _ = base64.RawURLEncoding.DecodeString(credentialID) byteCredentialPubKey, _ = base64.RawURLEncoding.DecodeString("pQMmIAEhWCAoCF-x0dwEhzQo-ABxHIAgr_5WL6cJceREc81oIwFn7iJYIHEHx8ZhBIE42L26-rSC_3l0ZaWEmsHAKyP9rgslApUdAQI") byteAAGUID, _ = base64.RawURLEncoding.DecodeString("rc4AAjW8xgpkiwsl8fBVAw") ) credentials := []Credential{ { ID: byteID, PublicKey: byteCredentialPubKey, Authenticator: Authenticator{ AAGUID: byteAAGUID, }, }, } user := &defaultUser{ id: byteUserHandle, credentials: credentials, } session := SessionData{ UserID: byteUserHandle, Challenge: "E4PTcIH_HfX1pC6Sigk1SC9NAlgeztN0439vi8z_c9k", AllowedCredentialIDs: [][]byte{[]byte("test"), byteID}, } webauthn := &WebAuthn{ Config: &Config{ RPDisplayName: "test_rp", RPOrigins: []string{"https://webauthn.io"}, RPID: "webauthn.io", }, } reqBody := io.NopCloser(bytes.NewReader([]byte(fmt.Sprintf(`{ "id":"%[1]s", "rawId":"%[1]s", "type":"public-key", "response":{ "authenticatorData":"dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBFXJJiGa3OAAI1vMYKZIsLJfHwVQMANwCOw-atj9C0vhWpfWU-whzNjeQS21Lpxfdk_G-omAtffWztpGoErlNOfuXWRqm9Uj9ANJck1p6lAQIDJiABIVggKAhfsdHcBIc0KPgAcRyAIK_-Vi-nCXHkRHPNaCMBZ-4iWCBxB8fGYQSBONi9uvq0gv95dGWlhJrBwCsj_a4LJQKVHQ", "clientDataJSON":"eyJjaGFsbGVuZ2UiOiJFNFBUY0lIX0hmWDFwQzZTaWdrMVNDOU5BbGdlenROMDQzOXZpOHpfYzlrIiwibmV3X2tleXNfbWF5X2JlX2FkZGVkX2hlcmUiOiJkbyBub3QgY29tcGFyZSBjbGllbnREYXRhSlNPTiBhZ2FpbnN0IGEgdGVtcGxhdGUuIFNlZSBodHRwczovL2dvby5nbC95YWJQZXgiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwidHlwZSI6IndlYmF1dGhuLmdldCJ9", "signature":"MEUCIBtIVOQxzFYdyWQyxaLR0tik1TnuPhGVhXVSNgFwLmN5AiEAnxXdCq0UeAVGWxOaFcjBZ_mEZoXqNboY5IkQDdlWZYc", "userHandle":"%[2]s" } }`, credentialID, userHandle, )))) httpReq := &http.Request{Body: reqBody} _, err := webauthn.FinishLogin(user, session, httpReq) require.Equal(t, protocol.ErrBadRequest.WithDetails("User does not own all credentials from the allowed credential list"), err) } func TestFinishLoginFailureCredentialOwnedButNotAllowedInSession(t *testing.T) { const ( credentialIDOne = "AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng" //nolint:gosec credentialIDTwo = "AI6D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng" //nolint:gosec userHandle = "0ToAAAAAAAAAAA" ) byteIDOne, err := base64.RawURLEncoding.DecodeString(credentialIDOne) require.NoError(t, err) byteIDTwo, err := base64.RawURLEncoding.DecodeString(credentialIDTwo) require.NoError(t, err) byteUserHandle, err := base64.RawURLEncoding.DecodeString(userHandle) require.NoError(t, err) byteCredentialPubKey, err := base64.RawURLEncoding.DecodeString("pQMmIAEhWCAoCF-x0dwEhzQo-ABxHIAgr_5WL6cJceREc81oIwFn7iJYIHEHx8ZhBIE42L26-rSC_3l0ZaWEmsHAKyP9rgslApUdAQI") require.NoError(t, err) byteAAGUID, err := base64.RawURLEncoding.DecodeString("rc4AAjW8xgpkiwsl8fBVAw") require.NoError(t, err) credentials := []Credential{ { ID: byteIDOne, PublicKey: byteCredentialPubKey, Authenticator: Authenticator{ AAGUID: byteAAGUID, }, }, { ID: byteIDTwo, PublicKey: byteCredentialPubKey, Authenticator: Authenticator{ AAGUID: byteAAGUID, }, }, } user := &defaultUser{ id: byteUserHandle, credentials: credentials, } session := SessionData{ UserID: byteUserHandle, Challenge: "E4PTcIH_HfX1pC6Sigk1SC9NAlgeztN0439vi8z_c9k", AllowedCredentialIDs: [][]byte{byteIDOne}, } webauthn := &WebAuthn{ Config: &Config{ RPDisplayName: "test_rp", RPOrigins: []string{"https://webauthn.io"}, RPID: "webauthn.io", }, } reqBody := io.NopCloser(bytes.NewReader([]byte(fmt.Sprintf(`{ "id":"%[1]s", "rawId":"%[1]s", "type":"public-key", "response":{ "authenticatorData":"dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBFXJJiGa3OAAI1vMYKZIsLJfHwVQMANwCOw-atj9C0vhWpfWU-whzNjeQS21Lpxfdk_G-omAtffWztpGoErlNOfuXWRqm9Uj9ANJck1p6lAQIDJiABIVggKAhfsdHcBIc0KPgAcRyAIK_-Vi-nCXHkRHPNaCMBZ-4iWCBxB8fGYQSBONi9uvq0gv95dGWlhJrBwCsj_a4LJQKVHQ", "clientDataJSON":"eyJjaGFsbGVuZ2UiOiJFNFBUY0lIX0hmWDFwQzZTaWdrMVNDOU5BbGdlenROMDQzOXZpOHpfYzlrIiwibmV3X2tleXNfbWF5X2JlX2FkZGVkX2hlcmUiOiJkbyBub3QgY29tcGFyZSBjbGllbnREYXRhSlNPTiBhZ2FpbnN0IGEgdGVtcGxhdGUuIFNlZSBodHRwczovL2dvby5nbC95YWJQZXgiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwidHlwZSI6IndlYmF1dGhuLmdldCJ9", "signature":"MEUCIBtIVOQxzFYdyWQyxaLR0tik1TnuPhGVhXVSNgFwLmN5AiEAnxXdCq0UeAVGWxOaFcjBZ_mEZoXqNboY5IkQDdlWZYc", "userHandle":"%[2]s" } }`, credentialIDTwo, userHandle, )))) httpReq := &http.Request{Body: reqBody} _, err = webauthn.FinishLogin(user, session, httpReq) require.Equal(t, protocol.ErrBadRequest.WithDetails("The credential ID provided is not in the sessions allowed credential list"), err) } func TestFinishLoginFailureCredentialNotOwned(t *testing.T) { const ( credentialIDOne = "AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng" //nolint:gosec credentialIDTwo = "AI6D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng" //nolint:gosec userHandle = "0ToAAAAAAAAAAA" ) byteIDOne, err := base64.RawURLEncoding.DecodeString(credentialIDOne) require.NoError(t, err) byteUserHandle, err := base64.RawURLEncoding.DecodeString(userHandle) require.NoError(t, err) byteCredentialPubKey, err := base64.RawURLEncoding.DecodeString("pQMmIAEhWCAoCF-x0dwEhzQo-ABxHIAgr_5WL6cJceREc81oIwFn7iJYIHEHx8ZhBIE42L26-rSC_3l0ZaWEmsHAKyP9rgslApUdAQI") require.NoError(t, err) byteAAGUID, err := base64.RawURLEncoding.DecodeString("rc4AAjW8xgpkiwsl8fBVAw") require.NoError(t, err) credentials := []Credential{ { ID: byteIDOne, PublicKey: byteCredentialPubKey, Authenticator: Authenticator{ AAGUID: byteAAGUID, }, }, } user := &defaultUser{ id: byteUserHandle, credentials: credentials, } session := SessionData{ UserID: byteUserHandle, Challenge: "E4PTcIH_HfX1pC6Sigk1SC9NAlgeztN0439vi8z_c9k", AllowedCredentialIDs: [][]byte{byteIDOne}, } webauthn := &WebAuthn{ Config: &Config{ RPDisplayName: "test_rp", RPOrigins: []string{"https://webauthn.io"}, RPID: "webauthn.io", }, } reqBody := io.NopCloser(bytes.NewReader([]byte(fmt.Sprintf(`{ "id":"%[1]s", "rawId":"%[1]s", "type":"public-key", "response":{ "authenticatorData":"dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBFXJJiGa3OAAI1vMYKZIsLJfHwVQMANwCOw-atj9C0vhWpfWU-whzNjeQS21Lpxfdk_G-omAtffWztpGoErlNOfuXWRqm9Uj9ANJck1p6lAQIDJiABIVggKAhfsdHcBIc0KPgAcRyAIK_-Vi-nCXHkRHPNaCMBZ-4iWCBxB8fGYQSBONi9uvq0gv95dGWlhJrBwCsj_a4LJQKVHQ", "clientDataJSON":"eyJjaGFsbGVuZ2UiOiJFNFBUY0lIX0hmWDFwQzZTaWdrMVNDOU5BbGdlenROMDQzOXZpOHpfYzlrIiwibmV3X2tleXNfbWF5X2JlX2FkZGVkX2hlcmUiOiJkbyBub3QgY29tcGFyZSBjbGllbnREYXRhSlNPTiBhZ2FpbnN0IGEgdGVtcGxhdGUuIFNlZSBodHRwczovL2dvby5nbC95YWJQZXgiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwidHlwZSI6IndlYmF1dGhuLmdldCJ9", "signature":"MEUCIBtIVOQxzFYdyWQyxaLR0tik1TnuPhGVhXVSNgFwLmN5AiEAnxXdCq0UeAVGWxOaFcjBZ_mEZoXqNboY5IkQDdlWZYc", "userHandle":"%[2]s" } }`, credentialIDTwo, userHandle, )))) httpReq := &http.Request{Body: reqBody} _, err = webauthn.FinishLogin(user, session, httpReq) require.Equal(t, &protocol.ErrorUnknownCredential{Err: protocol.ErrBadRequest.WithDetails("The credential ID provided is not owned by the user")}, err) } func TestFinishDiscoverableLogin_Failure(t *testing.T) { session := SessionData{} webauthn := &WebAuthn{} credential, err := webauthn.FinishDiscoverableLogin(nil, session, nil) assert.Nil(t, credential) assert.Error(t, err) } func TestFinishPasskeyLogin_Failure(t *testing.T) { session := SessionData{} webauthn := &WebAuthn{} user, credential, err := webauthn.FinishPasskeyLogin(nil, session, nil) assert.Nil(t, user) assert.Nil(t, credential) assert.Error(t, err) } func TestBeginLogin_EnforceTimeout(t *testing.T) { config := &Config{ RPID: "example.com", RPDisplayName: "Test Display Name", RPOrigins: []string{"https://example.com"}, Timeouts: TimeoutsConfig{ Login: TimeoutConfig{ Enforce: true, Timeout: time.Second * 60, }, }, } w, err := New(config) require.NoError(t, err) user := &defaultUser{ credentials: []Credential{{}}, } _, session, err := w.BeginLogin(user) require.NoError(t, err) assert.False(t, session.Expires.IsZero()) } func TestBeginDiscoverableLogin(t *testing.T) { testCases := []struct { name string config *Config opts []LoginOption expectedID string err string }{ { name: "ShouldSucceed", config: &Config{ RPID: "example.com", RPDisplayName: "Test Display Name", RPOrigins: []string{"https://example.com"}, }, expectedID: "example.com", }, { name: "ShouldSucceedWithOpts", config: &Config{ RPID: "example.com", RPDisplayName: "Test Display Name", RPOrigins: []string{"https://example.com"}, }, opts: []LoginOption{WithUserVerification(protocol.VerificationRequired)}, expectedID: "example.com", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { w, err := New(tc.config) require.NoError(t, err) assertion, session, err := w.BeginDiscoverableLogin(tc.opts...) if tc.err != "" { assert.EqualError(t, err, tc.err) } else { require.NoError(t, err) require.NotNil(t, assertion) require.NotNil(t, session) assert.Equal(t, tc.expectedID, assertion.Response.RelyingPartyID) assert.Empty(t, session.UserID) assert.Empty(t, session.AllowedCredentialIDs) } }) } } func TestBeginDiscoverableMediatedLogin(t *testing.T) { testCases := []struct { name string config *Config mediation protocol.CredentialMediationRequirement expectedID string expectedMediation protocol.CredentialMediationRequirement }{ { name: "ShouldSucceedConditional", config: &Config{ RPID: "example.com", RPDisplayName: "Test Display Name", RPOrigins: []string{"https://example.com"}, }, mediation: protocol.MediationConditional, expectedID: "example.com", expectedMediation: protocol.MediationConditional, }, { name: "ShouldSucceedRequired", config: &Config{ RPID: "example.com", RPDisplayName: "Test Display Name", RPOrigins: []string{"https://example.com"}, }, mediation: protocol.MediationRequired, expectedID: "example.com", expectedMediation: protocol.MediationRequired, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { w, err := New(tc.config) require.NoError(t, err) assertion, session, err := w.BeginDiscoverableMediatedLogin(tc.mediation) require.NoError(t, err) require.NotNil(t, assertion) require.NotNil(t, session) assert.Equal(t, tc.expectedID, assertion.Response.RelyingPartyID) assert.Equal(t, tc.expectedMediation, assertion.Mediation) assert.Empty(t, session.UserID) }) } } func TestBeginMediatedLogin_NoCredentials(t *testing.T) { config := &Config{ RPID: "example.com", RPDisplayName: "Test Display Name", RPOrigins: []string{"https://example.com"}, } w, err := New(config) require.NoError(t, err) user := &defaultUser{ id: []byte("123"), credentials: nil, } assertion, session, err := w.BeginMediatedLogin(user, protocol.MediationDefault) assert.Nil(t, assertion) assert.Nil(t, session) assert.EqualError(t, err, "Found no credentials for user") } func TestBeginLogin_Timeouts(t *testing.T) { testCases := []struct { name string config *Config opts []LoginOption expectedTimeout int }{ { name: "ShouldUseDefaultTimeout", config: &Config{ RPID: "example.com", RPDisplayName: "Test Display Name", RPOrigins: []string{"https://example.com"}, }, expectedTimeout: 300000, }, { name: "ShouldUseUVDTimeout", config: &Config{ RPID: "example.com", RPDisplayName: "Test Display Name", RPOrigins: []string{"https://example.com"}, AuthenticatorSelection: protocol.AuthenticatorSelection{ UserVerification: protocol.VerificationDiscouraged, }, }, expectedTimeout: 120000, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { w, err := New(tc.config) require.NoError(t, err) user := &defaultUser{ credentials: []Credential{{}}, } assertion, _, err := w.BeginLogin(user, tc.opts...) require.NoError(t, err) assert.Equal(t, tc.expectedTimeout, assertion.Response.Timeout) }) } } func TestValidateLogin_Errors(t *testing.T) { testCases := []struct { name string user User session SessionData err string }{ { name: "ShouldFailUserIDMismatch", user: &defaultUser{ id: []byte("123"), }, session: SessionData{ UserID: []byte("456"), }, err: "ID mismatch for User and Session", }, { name: "ShouldFailSessionExpired", user: &defaultUser{ id: []byte("123"), }, session: SessionData{ UserID: []byte("123"), Expires: time.Now().Add(-time.Hour), }, err: "Session has Expired", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { w := &WebAuthn{Config: &Config{ RPID: "example.com", RPOrigins: []string{"https://example.com"}, }} credential, err := w.ValidateLogin(tc.user, tc.session, nil) assert.Nil(t, credential) assert.EqualError(t, err, tc.err) }) } } func TestValidatePasskeyLogin_Errors(t *testing.T) { testCases := []struct { name string handler DiscoverableUserHandler session SessionData parsed *protocol.ParsedCredentialAssertionData err string }{ { name: "ShouldFailSessionNotDiscoverable", session: SessionData{ UserID: []byte("123"), }, err: "Session was not initiated as a client-side discoverable login", }, { name: "ShouldFailSessionExpired", session: SessionData{ Expires: time.Now().Add(-time.Hour), }, err: "Session has Expired", }, { name: "ShouldFailBlankUserHandle", session: SessionData{}, parsed: &protocol.ParsedCredentialAssertionData{ ParsedPublicKeyCredential: protocol.ParsedPublicKeyCredential{ RawID: []byte("cred-id"), }, Response: protocol.ParsedAssertionResponse{}, }, err: "Client-side Discoverable Assertion was attempted with a blank User Handle", }, { name: "ShouldFailHandlerError", handler: func(rawID, userHandle []byte) (User, error) { return nil, fmt.Errorf("user not found") }, session: SessionData{}, parsed: &protocol.ParsedCredentialAssertionData{ ParsedPublicKeyCredential: protocol.ParsedPublicKeyCredential{ RawID: []byte("cred-id"), }, Response: protocol.ParsedAssertionResponse{ UserHandle: []byte("user-handle"), }, }, err: "Failed to lookup Client-side Discoverable Credential: user not found", }, { name: "ShouldFailHandlerReturnsNilUser", handler: func(rawID, userHandle []byte) (User, error) { return nil, nil }, session: SessionData{}, parsed: &protocol.ParsedCredentialAssertionData{ ParsedPublicKeyCredential: protocol.ParsedPublicKeyCredential{ RawID: []byte("cred-id"), }, Response: protocol.ParsedAssertionResponse{ UserHandle: []byte("user-handle"), }, }, err: "Failed to lookup Client-side Discoverable Credential: handler returned a nil user", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { w := &WebAuthn{Config: &Config{ RPID: "example.com", RPOrigins: []string{"https://example.com"}, }} user, credential, err := w.ValidatePasskeyLogin(tc.handler, tc.session, tc.parsed) assert.Nil(t, user) assert.Nil(t, credential) require.EqualError(t, err, tc.err) }) } } func TestValidateDiscoverableLogin_Errors(t *testing.T) { w := &WebAuthn{Config: &Config{ RPID: "example.com", RPOrigins: []string{"https://example.com"}, }} credential, err := w.ValidateDiscoverableLogin(nil, SessionData{UserID: []byte("123")}, nil) assert.Nil(t, credential) require.EqualError(t, err, "Session was not initiated as a client-side discoverable login") } func TestLoginOptions(t *testing.T) { testCases := []struct { name string opts []LoginOption have protocol.PublicKeyCredentialRequestOptions expected protocol.PublicKeyCredentialRequestOptions }{ { name: "Empty", opts: nil, }, { name: "AllowedCredentials", opts: []LoginOption{WithAllowedCredentials([]protocol.CredentialDescriptor{{Type: protocol.PublicKeyCredentialType, CredentialID: []byte("123")}})}, expected: protocol.PublicKeyCredentialRequestOptions{ AllowedCredentials: []protocol.CredentialDescriptor{{Type: protocol.PublicKeyCredentialType, CredentialID: []byte("123")}}, }, }, { name: "UserVerification", opts: []LoginOption{WithUserVerification(protocol.VerificationRequired)}, expected: protocol.PublicKeyCredentialRequestOptions{ UserVerification: protocol.VerificationRequired, }, }, { name: "PublicKeyCredentialHints", opts: []LoginOption{WithAssertionPublicKeyCredentialHints([]protocol.PublicKeyCredentialHints{protocol.PublicKeyCredentialHintSecurityKey})}, expected: protocol.PublicKeyCredentialRequestOptions{ Hints: []protocol.PublicKeyCredentialHints{protocol.PublicKeyCredentialHintSecurityKey}, }, }, { name: "Extensions", opts: []LoginOption{WithAssertionExtensions(protocol.AuthenticationExtensions{"example": "extension"})}, expected: protocol.PublicKeyCredentialRequestOptions{ Extensions: protocol.AuthenticationExtensions{"example": "extension"}, }, }, { name: "AppIDExtensionWithoutU2F", opts: []LoginOption{WithAllowedCredentials([]protocol.CredentialDescriptor{{Type: protocol.PublicKeyCredentialType, CredentialID: []byte("123")}}), WithAppIdExtension("example")}, expected: protocol.PublicKeyCredentialRequestOptions{ AllowedCredentials: []protocol.CredentialDescriptor{{Type: protocol.PublicKeyCredentialType, CredentialID: []byte("123")}}, }, }, { name: "AppIDExtensionWithU2F", opts: []LoginOption{WithAllowedCredentials([]protocol.CredentialDescriptor{{Type: protocol.PublicKeyCredentialType, AttestationFormat: string(protocol.AttestationFormatFIDOUniversalSecondFactor), CredentialID: []byte("123")}}), WithAppIdExtension("example")}, expected: protocol.PublicKeyCredentialRequestOptions{ AllowedCredentials: []protocol.CredentialDescriptor{{Type: protocol.PublicKeyCredentialType, AttestationFormat: string(protocol.AttestationFormatFIDOUniversalSecondFactor), CredentialID: []byte("123")}}, Extensions: protocol.AuthenticationExtensions{protocol.ExtensionAppID: "example"}, }, }, { name: "RelyingPartyID", opts: []LoginOption{WithLoginRelyingPartyID("example.com")}, expected: protocol.PublicKeyCredentialRequestOptions{ RelyingPartyID: "example.com", }, }, { name: "Challenge", opts: []LoginOption{WithChallenge([]byte("00000000000000000000000000000000"))}, expected: protocol.PublicKeyCredentialRequestOptions{ Challenge: []byte("00000000000000000000000000000000"), }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { opts := &tc.have for _, opt := range tc.opts { opt(opts) } assert.Equal(t, tc.expected, *opts) }) } } func TestValidateLogin_Full(t *testing.T) { parsedResponse, credPubKey, challenge, credentialID := testLoginSpecVectorNoneES256(t) webauthn := &WebAuthn{ Config: &Config{ RPID: "example.org", RPOrigins: []string{"https://example.org"}, }, } userID := []byte("test-user-id") t.Run("ShouldSucceedNoAllowedCredentials", func(t *testing.T) { user := &defaultUser{ id: userID, credentials: []Credential{ { ID: credentialID, PublicKey: credPubKey, Flags: CredentialFlags{ UserPresent: true, BackupEligible: true, }, }, }, } session := SessionData{ UserID: userID, Challenge: challenge, } credential, err := webauthn.ValidateLogin(user, session, parsedResponse) require.NoError(t, err) require.NotNil(t, credential) assert.Equal(t, credentialID, credential.ID) assert.True(t, credential.Flags.UserPresent) }) t.Run("ShouldSucceedWithAllowedCredentials", func(t *testing.T) { user := &defaultUser{ id: userID, credentials: []Credential{ { ID: credentialID, PublicKey: credPubKey, Flags: CredentialFlags{ UserPresent: true, BackupEligible: true, }, }, }, } session := SessionData{ UserID: userID, Challenge: challenge, AllowedCredentialIDs: [][]byte{credentialID}, } credential, err := webauthn.ValidateLogin(user, session, parsedResponse) require.NoError(t, err) require.NotNil(t, credential) }) t.Run("ShouldFailUserHandleMismatch", func(t *testing.T) { parsedWithUserHandle, _, challengeUH, credIDUH := testLoginSpecVectorNoneES256(t) parsedWithUserHandle.Response.UserHandle = []byte("wrong-user-handle") user := &defaultUser{ id: userID, credentials: []Credential{ { ID: credIDUH, PublicKey: credPubKey, }, }, } session := SessionData{ UserID: userID, Challenge: challengeUH, } credential, err := webauthn.ValidateLogin(user, session, parsedWithUserHandle) assert.Nil(t, credential) assert.EqualError(t, err, "User handle and User ID do not match") }) t.Run("ShouldFailCredentialNotFound", func(t *testing.T) { user := &defaultUser{ id: userID, credentials: []Credential{ { ID: []byte("different-credential-id"), PublicKey: credPubKey, }, }, } session := SessionData{ UserID: userID, Challenge: challenge, } credential, err := webauthn.ValidateLogin(user, session, parsedResponse) assert.Nil(t, credential) assert.EqualError(t, err, "Unable to find the credential for the returned credential ID") }) t.Run("ShouldFailBackupEligibleFlagMismatch", func(t *testing.T) { user := &defaultUser{ id: userID, credentials: []Credential{ { ID: credentialID, PublicKey: credPubKey, Flags: CredentialFlags{ UserPresent: true, BackupEligible: false, }, }, }, } session := SessionData{ UserID: userID, Challenge: challenge, } credential, err := webauthn.ValidateLogin(user, session, parsedResponse) assert.Nil(t, credential) assert.EqualError(t, err, "Backup Eligible flag inconsistency detected during login validation") }) t.Run("ShouldFailVerifyError", func(t *testing.T) { user := &defaultUser{ id: userID, credentials: []Credential{ { ID: credentialID, PublicKey: []byte("invalid-public-key"), }, }, } session := SessionData{ UserID: userID, Challenge: challenge, } credential, err := webauthn.ValidateLogin(user, session, parsedResponse) assert.Nil(t, credential) require.Error(t, err) }) t.Run("ShouldSucceedWithMDSNilAAGUID", func(t *testing.T) { ctrl := gomock.NewController(t) provider := mocks.NewMockMetadataProvider(ctrl) w := &WebAuthn{ Config: &Config{ RPID: "example.org", RPOrigins: []string{"https://example.org"}, MDS: provider, }, } user := &defaultUser{ id: userID, credentials: []Credential{ { ID: credentialID, PublicKey: credPubKey, Flags: CredentialFlags{ UserPresent: true, BackupEligible: true, }, }, }, } session := SessionData{ UserID: userID, Challenge: challenge, } provider.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(nil, nil) provider.EXPECT().GetValidateEntryPermitZeroAAGUID(gomock.Any()).Return(true) credential, err := w.ValidateLogin(user, session, parsedResponse) require.NoError(t, err) require.NotNil(t, credential) }) t.Run("ShouldFailWithMDSGetEntryError", func(t *testing.T) { ctrl := gomock.NewController(t) provider := mocks.NewMockMetadataProvider(ctrl) w := &WebAuthn{ Config: &Config{ RPID: "example.org", RPOrigins: []string{"https://example.org"}, MDS: provider, }, } user := &defaultUser{ id: userID, credentials: []Credential{ { ID: credentialID, PublicKey: credPubKey, }, }, } session := SessionData{ UserID: userID, Challenge: challenge, } provider.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("entry not found")) credential, err := w.ValidateLogin(user, session, parsedResponse) assert.Nil(t, credential) assert.EqualError(t, err, "Failed to validate credential record metadata") }) t.Run("ShouldSucceedWithMDSAndAAGUID", func(t *testing.T) { ctrl := gomock.NewController(t) provider := mocks.NewMockMetadataProvider(ctrl) w := &WebAuthn{ Config: &Config{ RPID: "example.org", RPOrigins: []string{"https://example.org"}, MDS: provider, }, } aaguid := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10} user := &defaultUser{ id: userID, credentials: []Credential{ { ID: credentialID, PublicKey: credPubKey, AttestationType: "packed", Flags: CredentialFlags{ UserPresent: true, BackupEligible: true, }, Authenticator: Authenticator{ AAGUID: aaguid, }, }, }, } session := SessionData{ UserID: userID, Challenge: challenge, } provider.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(nil, nil) provider.EXPECT().GetValidateEntry(gomock.Any()).Return(false) credential, err := w.ValidateLogin(user, session, parsedResponse) require.NoError(t, err) require.NotNil(t, credential) }) t.Run("ShouldFailWithMDSInvalidAAGUID", func(t *testing.T) { ctrl := gomock.NewController(t) provider := mocks.NewMockMetadataProvider(ctrl) w := &WebAuthn{ Config: &Config{ RPID: "example.org", RPOrigins: []string{"https://example.org"}, MDS: provider, }, } user := &defaultUser{ id: userID, credentials: []Credential{ { ID: credentialID, PublicKey: credPubKey, Authenticator: Authenticator{ AAGUID: []byte{0x01, 0x02, 0x03}, }, }, }, } session := SessionData{ UserID: userID, Challenge: challenge, } credential, err := w.ValidateLogin(user, session, parsedResponse) assert.Nil(t, credential) assert.EqualError(t, err, "Failed to decode AAGUID") }) t.Run("ShouldSucceedWithMDSValidateStatusReports", func(t *testing.T) { ctrl := gomock.NewController(t) provider := mocks.NewMockMetadataProvider(ctrl) w := &WebAuthn{ Config: &Config{ RPID: "example.org", RPOrigins: []string{"https://example.org"}, MDS: provider, }, } user := &defaultUser{ id: userID, credentials: []Credential{ { ID: credentialID, PublicKey: credPubKey, AttestationType: "basic_full", AttestationFormat: "packed", Flags: CredentialFlags{ UserPresent: true, BackupEligible: true, }, }, }, } session := SessionData{ UserID: userID, Challenge: challenge, } provider.EXPECT().GetEntry(gomock.Any(), gomock.Any()).Return(&metadata.Entry{ MetadataStatement: metadata.Statement{ AttestationTypes: metadata.AuthenticatorAttestationTypes{metadata.BasicFull}, }, }, nil) provider.EXPECT().GetValidateAttestationTypes(gomock.Any()).Return(true) provider.EXPECT().GetValidateStatus(gomock.Any()).Return(true) provider.EXPECT().ValidateStatusReports(gomock.Any(), gomock.Any()).Return(nil) provider.EXPECT().GetValidateTrustAnchor(gomock.Any()).Return(false) credential, err := w.ValidateLogin(user, session, parsedResponse) require.NoError(t, err) require.NotNil(t, credential) }) } func TestValidatePasskeyLogin_Full(t *testing.T) { parsedResponse, credPubKey, challenge, credentialID := testLoginSpecVectorNoneES256(t) userID := []byte("test-user-id") parsedResponse.Response.UserHandle = userID t.Run("ShouldSucceed", func(t *testing.T) { w := &WebAuthn{ Config: &Config{ RPID: "example.org", RPOrigins: []string{"https://example.org"}, }, } user := &defaultUser{ id: userID, credentials: []Credential{ { ID: credentialID, PublicKey: credPubKey, Flags: CredentialFlags{ UserPresent: true, BackupEligible: true, }, }, }, } session := SessionData{ Challenge: challenge, } handler := func(rawID, userHandle []byte) (User, error) { return user, nil } returnedUser, credential, err := w.ValidatePasskeyLogin(handler, session, parsedResponse) require.NoError(t, err) require.NotNil(t, returnedUser) require.NotNil(t, credential) assert.Equal(t, credentialID, credential.ID) }) t.Run("ShouldFailValidateLoginError", func(t *testing.T) { w := &WebAuthn{ Config: &Config{ RPID: "example.org", RPOrigins: []string{"https://example.org"}, }, } user := &defaultUser{ id: userID, credentials: []Credential{ { ID: []byte("different-id"), PublicKey: credPubKey, }, }, } session := SessionData{ Challenge: challenge, } handler := func(rawID, userHandle []byte) (User, error) { return user, nil } returnedUser, credential, err := w.ValidatePasskeyLogin(handler, session, parsedResponse) assert.Nil(t, returnedUser) assert.Nil(t, credential) require.Error(t, err) }) } func TestFinishDiscoverableLogin_Success(t *testing.T) { parsedResponse, credPubKey, challenge, credentialID := testLoginSpecVectorNoneES256(t) userID := []byte("test-user-id") body := map[string]any{ "id": base64.RawURLEncoding.EncodeToString(credentialID), "rawId": base64.RawURLEncoding.EncodeToString(credentialID), "type": "public-key", "response": map[string]any{ "authenticatorData": base64.RawURLEncoding.EncodeToString(parsedResponse.Raw.AssertionResponse.AuthenticatorData), "clientDataJSON": base64.RawURLEncoding.EncodeToString(parsedResponse.Raw.AssertionResponse.ClientDataJSON), "signature": base64.RawURLEncoding.EncodeToString(parsedResponse.Response.Signature), "userHandle": base64.RawURLEncoding.EncodeToString(userID), }, } data, err := json.Marshal(body) require.NoError(t, err) w := &WebAuthn{ Config: &Config{ RPID: "example.org", RPOrigins: []string{"https://example.org"}, }, } user := &defaultUser{ id: userID, credentials: []Credential{ { ID: credentialID, PublicKey: credPubKey, Flags: CredentialFlags{ UserPresent: true, BackupEligible: true, }, }, }, } session := SessionData{ Challenge: challenge, } handler := func(rawID, userHandle []byte) (User, error) { return user, nil } reqBody := io.NopCloser(bytes.NewReader(data)) httpReq := &http.Request{Body: reqBody} credential, err := w.FinishDiscoverableLogin(handler, session, httpReq) require.NoError(t, err) require.NotNil(t, credential) assert.Equal(t, credentialID, credential.ID) } func TestFinishPasskeyLogin_Success(t *testing.T) { parsedResponse, credPubKey, challenge, credentialID := testLoginSpecVectorNoneES256(t) userID := []byte("test-user-id") body := map[string]any{ "id": base64.RawURLEncoding.EncodeToString(credentialID), "rawId": base64.RawURLEncoding.EncodeToString(credentialID), "type": "public-key", "response": map[string]any{ "authenticatorData": base64.RawURLEncoding.EncodeToString(parsedResponse.Raw.AssertionResponse.AuthenticatorData), "clientDataJSON": base64.RawURLEncoding.EncodeToString(parsedResponse.Raw.AssertionResponse.ClientDataJSON), "signature": base64.RawURLEncoding.EncodeToString(parsedResponse.Response.Signature), "userHandle": base64.RawURLEncoding.EncodeToString(userID), }, } data, err := json.Marshal(body) require.NoError(t, err) w := &WebAuthn{ Config: &Config{ RPID: "example.org", RPOrigins: []string{"https://example.org"}, }, } user := &defaultUser{ id: userID, credentials: []Credential{ { ID: credentialID, PublicKey: credPubKey, Flags: CredentialFlags{ UserPresent: true, BackupEligible: true, }, }, }, } session := SessionData{ Challenge: challenge, } handler := func(rawID, userHandle []byte) (User, error) { return user, nil } reqBody := io.NopCloser(bytes.NewReader(data)) httpReq := &http.Request{Body: reqBody} returnedUser, credential, err := w.FinishPasskeyLogin(handler, session, httpReq) require.NoError(t, err) require.NotNil(t, returnedUser) require.NotNil(t, credential) assert.Equal(t, credentialID, credential.ID) } // testLoginSpecVectorNoneES256 returns the spec test vector data for NoneES256 authentication. // See: https://www.w3.org/TR/webauthn-3/#sctn-test-vectors-none-es256 func testLoginSpecVectorNoneES256(t *testing.T) (parsedResponse *protocol.ParsedCredentialAssertionData, credPubKey []byte, challenge string, credentialID []byte) { t.Helper() const ( authenticatorDataHex = "bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b51900000000" clientDataJSONHex = "7b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a224f63446e55685158756c5455506f334a5558543049393770767a7a59425039745a63685879617630314167222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73657d" signatureHex = "3046022100f50a4e2e4409249c4a853ba361282f09841df4dd4547a13a87780218deffcd380221008480ac0f0b93538174f575bf11a1dd5d78c6e486013f937295ea13653e331e87" credentialIDHex = "f91f391db4c9b2fde0ea70189cba3fb63f579ba6122b33ad94ff3ec330084be4" //nolint:gosec challengeHex = "39c0e7521417ba54d43e8dc95174f423dee9bf3cd804ff6d65c857c9abf4d408" credentialPubKeyHex = "a5010203262001215820afefa16f97ca9b2d23eb86ccb64098d20db90856062eb249c33a9b672f26df61225820930a56b87a2fca66334b03458abf879717c12cc68ed73290af2e2664796b9220" ) credentialID, err := hex.DecodeString(credentialIDHex) require.NoError(t, err) credPubKey, err = hex.DecodeString(credentialPubKeyHex) require.NoError(t, err) challenge = base64.RawURLEncoding.EncodeToString(testDecodeHex(t, challengeHex)) id := base64.RawURLEncoding.EncodeToString(credentialID) authenticatorData := base64.RawURLEncoding.EncodeToString(testDecodeHex(t, authenticatorDataHex)) clientDataJSON := base64.RawURLEncoding.EncodeToString(testDecodeHex(t, clientDataJSONHex)) signature := base64.RawURLEncoding.EncodeToString(testDecodeHex(t, signatureHex)) body := map[string]any{ "id": id, "rawId": id, "type": "public-key", "response": map[string]any{ "authenticatorData": authenticatorData, "clientDataJSON": clientDataJSON, "signature": signature, }, } data, err := json.Marshal(body) require.NoError(t, err) parsedResponse, err = protocol.ParseCredentialRequestResponseBytes(data) require.NoError(t, err) return parsedResponse, credPubKey, challenge, credentialID } func testDecodeHex(t *testing.T, s string) []byte { t.Helper() data, err := hex.DecodeString(s) require.NoError(t, err) return data }