diff --git a/internal/api/passkey_manage.go b/internal/api/passkey_manage.go index 0259d0876b..94a487366e 100644 --- a/internal/api/passkey_manage.go +++ b/internal/api/passkey_manage.go @@ -17,6 +17,7 @@ import ( type PasskeyListItem struct { ID string `json:"id"` FriendlyName string `json:"friendly_name,omitempty"` + AAGUID *uuid.UUID `json:"aaguid,omitempty"` CreatedAt time.Time `json:"created_at"` LastUsedAt *time.Time `json:"last_used_at,omitempty"` } @@ -152,6 +153,7 @@ func toPasskeyListItem(cred *models.WebAuthnCredential) PasskeyListItem { return PasskeyListItem{ ID: cred.ID.String(), FriendlyName: cred.FriendlyName, + AAGUID: cred.AAGUID, CreatedAt: cred.CreatedAt, LastUsedAt: cred.LastUsedAt, } diff --git a/internal/api/passkey_manage_test.go b/internal/api/passkey_manage_test.go index eee3167660..8b8d0c605f 100644 --- a/internal/api/passkey_manage_test.go +++ b/internal/api/passkey_manage_test.go @@ -13,12 +13,14 @@ import ( // createTestPasskey creates a WebAuthnCredential for the given user in the database. func (ts *PasskeyTestSuite) createTestPasskey(userID uuid.UUID, friendlyName string) *models.WebAuthnCredential { + testAAGUID := uuid.Must(uuid.NewV4()) cred := &models.WebAuthnCredential{ ID: uuid.Must(uuid.NewV4()), UserID: userID, CredentialID: fmt.Appendf(nil, "cred-%s", uuid.Must(uuid.NewV4()).String()[:8]), PublicKey: []byte("test-public-key"), AttestationType: "none", + AAGUID: &testAAGUID, FriendlyName: friendlyName, BackupEligible: true, BackedUp: false, @@ -56,10 +58,14 @@ func (ts *PasskeyTestSuite) TestPasskeyListWithPasskeys() { // Results are ordered by created_at asc ts.Equal(pk1.ID.String(), items[0].ID) ts.Equal("My iPhone", items[0].FriendlyName) + ts.Require().NotNil(items[0].AAGUID, "AAGUID should be present in list response") + ts.Equal(pk1.AAGUID.String(), items[0].AAGUID.String()) ts.Nil(items[0].LastUsedAt) ts.Equal(pk2.ID.String(), items[1].ID) ts.Equal("My MacBook", items[1].FriendlyName) + ts.Require().NotNil(items[1].AAGUID, "AAGUID should be present in list response") + ts.Equal(pk2.AAGUID.String(), items[1].AAGUID.String()) ts.Nil(items[1].LastUsedAt) } @@ -103,6 +109,8 @@ func (ts *PasskeyTestSuite) TestPasskeyUpdateFriendlyName() { require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&item)) ts.Equal("New Name", item.FriendlyName) ts.Equal(cred.ID.String(), item.ID) + ts.Require().NotNil(item.AAGUID, "AAGUID should be preserved after rename") + ts.Equal(cred.AAGUID.String(), item.AAGUID.String()) updated, err := models.FindWebAuthnCredentialByID(ts.API.db, cred.ID) require.NoError(ts.T(), err) diff --git a/internal/api/passkey_registration.go b/internal/api/passkey_registration.go index 72f9c75d09..825f42cc41 100644 --- a/internal/api/passkey_registration.go +++ b/internal/api/passkey_registration.go @@ -28,12 +28,14 @@ type PasskeyRegistrationOptionsResponse struct { type PasskeyRegistrationVerifyParams struct { ChallengeID string `json:"challenge_id"` CredentialResponse json.RawMessage `json:"credential_response"` + FriendlyName string `json:"friendly_name,omitempty"` } // PasskeyMetadataResponse is the response body for successful passkey creation. type PasskeyMetadataResponse struct { ID string `json:"id"` FriendlyName string `json:"friendly_name,omitempty"` + AAGUID *uuid.UUID `json:"aaguid,omitempty"` CreatedAt time.Time `json:"created_at"` BackupEligible bool `json:"backup_eligible"` BackedUp bool `json:"backed_up"` @@ -132,6 +134,9 @@ func (a *API) PasskeyRegistrationVerify(w http.ResponseWriter, r *http.Request) if params.CredentialResponse == nil { return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "credential_response is required") } + if len(params.FriendlyName) > 120 { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "friendly_name must be 120 characters or less") + } challengeID, err := uuid.FromString(params.ChallengeID) if err != nil { @@ -177,7 +182,10 @@ func (a *API) PasskeyRegistrationVerify(w http.ResponseWriter, r *http.Request) return apierrors.NewBadRequestError(apierrors.ErrorCodeWebAuthnVerificationFailed, "Credential verification failed").WithInternalError(err) } - friendlyName := utilities.PasskeyFriendlyName(credential.Authenticator.AAGUID) + friendlyName := params.FriendlyName + if friendlyName == "" { + friendlyName = utilities.PasskeyFriendlyName(credential.Authenticator.AAGUID) + } passkeyCredential := models.NewWebAuthnCredential(user.ID, credential, friendlyName) err = db.Transaction(func(tx *storage.Connection) error { @@ -215,6 +223,7 @@ func (a *API) PasskeyRegistrationVerify(w http.ResponseWriter, r *http.Request) return sendJSON(w, http.StatusOK, &PasskeyMetadataResponse{ ID: passkeyCredential.ID.String(), FriendlyName: passkeyCredential.FriendlyName, + AAGUID: passkeyCredential.AAGUID, CreatedAt: passkeyCredential.CreatedAt, BackupEligible: passkeyCredential.BackupEligible, BackedUp: passkeyCredential.BackedUp, diff --git a/internal/api/passkey_registration_test.go b/internal/api/passkey_registration_test.go index dee99ed54b..7e9670bce8 100644 --- a/internal/api/passkey_registration_test.go +++ b/internal/api/passkey_registration_test.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" "net/http" + "strings" "time" "github.com/gofrs/uuid" @@ -45,6 +46,7 @@ func (ts *PasskeyTestSuite) TestRegisterPasskeyHappyPath() { ts.NotEmpty(passkeyResp.ID) ts.NotZero(passkeyResp.CreatedAt) + ts.NotNil(passkeyResp.AAGUID, "AAGUID should be present in registration response") // Step 4: Verify the credential was persisted passkeyID, err := uuid.FromString(passkeyResp.ID) @@ -63,6 +65,62 @@ func (ts *PasskeyTestSuite) TestRegisterPasskeyHappyPath() { ts.True(models.IsNotFoundError(err)) } +// TestRegisterPasskeyWithCustomFriendlyName tests that providing a friendly_name during registration +// overrides the default AAGUID-based name. +func (ts *PasskeyTestSuite) TestRegisterPasskeyWithCustomFriendlyName() { + token := ts.generateToken(ts.TestUser, &ts.TestSession.ID) + + // Step 1: Get registration options + w := ts.makeRequest(http.MethodPost, "http://localhost/passkeys/registration/options", nil, withBearerToken(token)) + ts.Require().Equal(http.StatusOK, w.Code) + + var optionsResp PasskeyRegistrationOptionsResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&optionsResp)) + + // Step 2: Simulate the authenticator creating a credential + authenticator := &virtualAuthenticator{ + rpID: ts.Config.WebAuthn.RPID, + origin: ts.Config.WebAuthn.RPOrigins[0], + } + + credResp, err := authenticator.createCredential(optionsResp.Options) + require.NoError(ts.T(), err) + + // Step 3: Verify with a custom friendly_name + customName := "iCloud Keychain (Mar 13, 2026, 2:30:42 PM)" + w = ts.makeRequest(http.MethodPost, "http://localhost/passkeys/registration/verify", map[string]any{ + "challenge_id": optionsResp.ChallengeID, + "credential_response": json.RawMessage(credResp.JSON), + "friendly_name": customName, + }, withBearerToken(token)) + ts.Require().Equal(http.StatusOK, w.Code) + + var passkeyResp PasskeyMetadataResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&passkeyResp)) + + ts.Equal(customName, passkeyResp.FriendlyName) + + // Verify persisted in database + passkeyID, err := uuid.FromString(passkeyResp.ID) + require.NoError(ts.T(), err) + cred, err := models.FindWebAuthnCredentialByID(ts.API.db, passkeyID) + require.NoError(ts.T(), err) + ts.Equal(customName, cred.FriendlyName) +} + +// TestRegisterPasskeyFriendlyNameTooLong tests that a friendly_name exceeding 120 chars is rejected. +func (ts *PasskeyTestSuite) TestRegisterPasskeyFriendlyNameTooLong() { + token := ts.generateToken(ts.TestUser, &ts.TestSession.ID) + + w := ts.makeRequest(http.MethodPost, "http://localhost/passkeys/registration/verify", map[string]any{ + "challenge_id": uuid.Must(uuid.NewV4()).String(), + "credential_response": map[string]any{}, + "friendly_name": strings.Repeat("a", 121), + }, withBearerToken(token)) + + ts.Equal(http.StatusBadRequest, w.Code) +} + // TestRegistrationOptionsSuccess tests that an authenticated user can get registration options. func (ts *PasskeyTestSuite) TestRegistrationOptionsSuccess() { token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)