Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/api/passkey_manage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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,
}
Expand Down
8 changes: 8 additions & 0 deletions internal/api/passkey_manage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
Expand Down
11 changes: 10 additions & 1 deletion internal/api/passkey_registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand 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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
58 changes: 58 additions & 0 deletions internal/api/passkey_registration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api
import (
"encoding/json"
"net/http"
"strings"
"time"

"github.com/gofrs/uuid"
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down