Skip to content
Merged
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
19 changes: 18 additions & 1 deletion libs/accounts/passkey/src/lib/passkey.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,15 +753,24 @@ describe('PasskeyService', () => {
describe('renamePasskey', () => {
beforeEach(() => {
mockManager.renamePasskey.mockResolvedValue(true);
mockManager.findPasskeyByCredentialId.mockResolvedValue(mockPasskey);
});

it('calls manager.renamePasskey and emits metrics and security log on success', async () => {
await service.renamePasskey(MOCK_UID, MOCK_CREDENTIAL_ID, 'New Name');
const result = await service.renamePasskey(
MOCK_UID,
MOCK_CREDENTIAL_ID,
'New Name'
);
expect(mockManager.renamePasskey).toHaveBeenCalledWith(
MOCK_UID,
MOCK_CREDENTIAL_ID,
'New Name'
);
expect(mockManager.findPasskeyByCredentialId).toHaveBeenCalledWith(
MOCK_CREDENTIAL_ID
);
expect(result).toBe(mockPasskey);
expect(mockMetrics.increment).toHaveBeenCalledWith(
'passkey.rename.success'
);
Expand All @@ -788,6 +797,14 @@ describe('PasskeyService', () => {
).rejects.toMatchObject(AppError.passkeyNotFound());
});

it('throws passkeyNotFound if the passkey cannot be fetched after rename', async () => {
mockManager.findPasskeyByCredentialId.mockResolvedValue(undefined);

await expect(
service.renamePasskey(MOCK_UID, MOCK_CREDENTIAL_ID, 'New Name')
).rejects.toMatchObject(AppError.passkeyNotFound());
});

it('throws AppError passkeyInvalidName when name is empty', async () => {
await expect(
service.renamePasskey(MOCK_UID, MOCK_CREDENTIAL_ID, '')
Expand Down
13 changes: 12 additions & 1 deletion libs/accounts/passkey/src/lib/passkey.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ export class PasskeyService {
uid: Buffer,
credentialId: Buffer,
newName: string
): Promise<void> {
): Promise<Passkey> {
const trimmed = newName.trim();
if (
!trimmed ||
Expand Down Expand Up @@ -278,8 +278,19 @@ export class PasskeyService {
throw AppError.passkeyNotFound();
}

const passkey =
await this.passkeyManager.findPasskeyByCredentialId(credentialId);
if (!passkey) {
this.metrics.increment('passkey.rename.failed', {
reason: 'notFound',
});
throw AppError.passkeyNotFound();
}

this.metrics.increment('passkey.rename.success');
this.log?.log('passkey.renamed', { uid: uid.toString('hex') });

return passkey;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/fxa-auth-server/bin/key_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ async function run(config) {
...config.redis,
...config.redis.passkey,
});
const passkeyConfig = buildPasskeyConfig(config.passkeys, log);
const passkeyConfig = buildPasskeyConfig(config.passkeys);
const passkeyManager = new PasskeyManager(
accountDatabase,
passkeyConfig,
Expand Down
2 changes: 1 addition & 1 deletion packages/fxa-auth-server/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2740,7 +2740,7 @@ const convictConf = convict({
env: 'MFA__ENABLED',
},
actions: {
default: ['test', '2fa', 'email', 'recovery_key', 'password', 'passkeys'],
default: ['test', '2fa', 'email', 'recovery_key', 'password', 'passkey'],
doc: 'Actions protected by MFA',
format: Array,
env: 'MFA__ACTIONS',
Expand Down
59 changes: 59 additions & 0 deletions packages/fxa-auth-server/docs/swagger/passkeys-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,65 @@ const PASSKEY_REGISTRATION_FINISH_POST = {
const PASSKEYS_API_DOCS = {
PASSKEY_REGISTRATION_START_POST,
PASSKEY_REGISTRATION_FINISH_POST,
PASSKEYS_GET: {
...TAGS_PASSKEYS,
description: '/passkeys',
notes: [
dedent`
🔒 Authenticated with session token (verified)

Returns the list of passkeys registered for the authenticated user.
The \`publicKey\` and \`signCount\` fields are intentionally excluded
from the response as they are internal implementation details.

**Response:** Array of passkey metadata objects, each containing
\`credentialId\`, \`name\`, \`createdAt\`, \`lastUsedAt\`, \`transports\`, and \`prfEnabled\`.
`,
],
},
PASSKEY_CREDENTIAL_DELETE: {
...TAGS_PASSKEYS,
description: '/passkey/{credentialId}',
notes: [
dedent`
🔒 Authenticated with MFA JWT (scope: mfa:passkey)

Deletes the passkey identified by \`credentialId\` (base64url-encoded).
The service validates that the passkey exists and belongs to the
authenticated user. Returns 404 if the passkey is not found or is
not owned by the user.

**Params:**
- \`credentialId\` (string, required) — base64url-encoded credential ID

**Security event:** \`account.passkey.removed\` is recorded on success.
`,
],
},
PASSKEY_CREDENTIAL_PATCH: {
...TAGS_PASSKEYS,
description: '/passkey/{credentialId}',
notes: [
dedent`
🔒 Authenticated with MFA JWT (scope: mfa:passkey)

Renames the passkey identified by \`credentialId\` (base64url-encoded).
The new name must be 1–255 characters and non-empty after trimming.
The service validates that the passkey exists and belongs to the
authenticated user. Returns 404 if the passkey is not found or is
not owned by the user.

**Params:**
- \`credentialId\` (string, required) — base64url-encoded credential ID

**Request body:**
- \`name\` (string, required) — new display name (1–255 chars)

**Response:** Updated passkey metadata including \`credentialId\`, \`name\`,
\`createdAt\`, \`lastUsedAt\`, \`transports\`, and \`prfEnabled\`.
`,
],
},
};

export default PASSKEYS_API_DOCS;
2 changes: 2 additions & 0 deletions packages/fxa-auth-server/lib/metrics/glean/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,8 @@ export function gleanMetrics(config: ConfigType) {
// registrationStarted: createEventFn('passkey_registration_started'),
// registrationComplete: createEventFn('passkey_registration_complete'),
// registrationFailed: createEventFn('passkey_registration_failed'),
// deleteSuccess: createEventFn('passkey_delete_success'),
// renameSuccess: createEventFn('passkey_rename_success'),
// },
};
}
Expand Down
Loading
Loading