Skip to content

Commit ef4fc24

Browse files
Merge pull request #20286 from mozilla/FXA-13070
feat(passkey): create passkey management API endpoints
2 parents d82cf8c + 5f0dbd4 commit ef4fc24

12 files changed

Lines changed: 909 additions & 137 deletions

File tree

libs/accounts/passkey/src/lib/passkey.service.spec.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -753,15 +753,24 @@ describe('PasskeyService', () => {
753753
describe('renamePasskey', () => {
754754
beforeEach(() => {
755755
mockManager.renamePasskey.mockResolvedValue(true);
756+
mockManager.findPasskeyByCredentialId.mockResolvedValue(mockPasskey);
756757
});
757758

758759
it('calls manager.renamePasskey and emits metrics and security log on success', async () => {
759-
await service.renamePasskey(MOCK_UID, MOCK_CREDENTIAL_ID, 'New Name');
760+
const result = await service.renamePasskey(
761+
MOCK_UID,
762+
MOCK_CREDENTIAL_ID,
763+
'New Name'
764+
);
760765
expect(mockManager.renamePasskey).toHaveBeenCalledWith(
761766
MOCK_UID,
762767
MOCK_CREDENTIAL_ID,
763768
'New Name'
764769
);
770+
expect(mockManager.findPasskeyByCredentialId).toHaveBeenCalledWith(
771+
MOCK_CREDENTIAL_ID
772+
);
773+
expect(result).toBe(mockPasskey);
765774
expect(mockMetrics.increment).toHaveBeenCalledWith(
766775
'passkey.rename.success'
767776
);
@@ -788,6 +797,14 @@ describe('PasskeyService', () => {
788797
).rejects.toMatchObject(AppError.passkeyNotFound());
789798
});
790799

800+
it('throws passkeyNotFound if the passkey cannot be fetched after rename', async () => {
801+
mockManager.findPasskeyByCredentialId.mockResolvedValue(undefined);
802+
803+
await expect(
804+
service.renamePasskey(MOCK_UID, MOCK_CREDENTIAL_ID, 'New Name')
805+
).rejects.toMatchObject(AppError.passkeyNotFound());
806+
});
807+
791808
it('throws AppError passkeyInvalidName when name is empty', async () => {
792809
await expect(
793810
service.renamePasskey(MOCK_UID, MOCK_CREDENTIAL_ID, '')

libs/accounts/passkey/src/lib/passkey.service.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ export class PasskeyService {
248248
uid: Buffer,
249249
credentialId: Buffer,
250250
newName: string
251-
): Promise<void> {
251+
): Promise<Passkey> {
252252
const trimmed = newName.trim();
253253
if (
254254
!trimmed ||
@@ -278,8 +278,19 @@ export class PasskeyService {
278278
throw AppError.passkeyNotFound();
279279
}
280280

281+
const passkey =
282+
await this.passkeyManager.findPasskeyByCredentialId(credentialId);
283+
if (!passkey) {
284+
this.metrics.increment('passkey.rename.failed', {
285+
reason: 'notFound',
286+
});
287+
throw AppError.passkeyNotFound();
288+
}
289+
281290
this.metrics.increment('passkey.rename.success');
282291
this.log?.log('passkey.renamed', { uid: uid.toString('hex') });
292+
293+
return passkey;
283294
}
284295

285296
/**

packages/fxa-auth-server/bin/key_server.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ async function run(config) {
310310
...config.redis,
311311
...config.redis.passkey,
312312
});
313-
const passkeyConfig = buildPasskeyConfig(config.passkeys, log);
313+
const passkeyConfig = buildPasskeyConfig(config.passkeys);
314314
const passkeyManager = new PasskeyManager(
315315
accountDatabase,
316316
passkeyConfig,

packages/fxa-auth-server/config/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2740,7 +2740,7 @@ const convictConf = convict({
27402740
env: 'MFA__ENABLED',
27412741
},
27422742
actions: {
2743-
default: ['test', '2fa', 'email', 'recovery_key', 'password', 'passkeys'],
2743+
default: ['test', '2fa', 'email', 'recovery_key', 'password', 'passkey'],
27442744
doc: 'Actions protected by MFA',
27452745
format: Array,
27462746
env: 'MFA__ACTIONS',

packages/fxa-auth-server/docs/swagger/passkeys-api.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,65 @@ const PASSKEY_REGISTRATION_FINISH_POST = {
6363
const PASSKEYS_API_DOCS = {
6464
PASSKEY_REGISTRATION_START_POST,
6565
PASSKEY_REGISTRATION_FINISH_POST,
66+
PASSKEYS_GET: {
67+
...TAGS_PASSKEYS,
68+
description: '/passkeys',
69+
notes: [
70+
dedent`
71+
🔒 Authenticated with session token (verified)
72+
73+
Returns the list of passkeys registered for the authenticated user.
74+
The \`publicKey\` and \`signCount\` fields are intentionally excluded
75+
from the response as they are internal implementation details.
76+
77+
**Response:** Array of passkey metadata objects, each containing
78+
\`credentialId\`, \`name\`, \`createdAt\`, \`lastUsedAt\`, \`transports\`, and \`prfEnabled\`.
79+
`,
80+
],
81+
},
82+
PASSKEY_CREDENTIAL_DELETE: {
83+
...TAGS_PASSKEYS,
84+
description: '/passkey/{credentialId}',
85+
notes: [
86+
dedent`
87+
🔒 Authenticated with MFA JWT (scope: mfa:passkey)
88+
89+
Deletes the passkey identified by \`credentialId\` (base64url-encoded).
90+
The service validates that the passkey exists and belongs to the
91+
authenticated user. Returns 404 if the passkey is not found or is
92+
not owned by the user.
93+
94+
**Params:**
95+
- \`credentialId\` (string, required) — base64url-encoded credential ID
96+
97+
**Security event:** \`account.passkey.removed\` is recorded on success.
98+
`,
99+
],
100+
},
101+
PASSKEY_CREDENTIAL_PATCH: {
102+
...TAGS_PASSKEYS,
103+
description: '/passkey/{credentialId}',
104+
notes: [
105+
dedent`
106+
🔒 Authenticated with MFA JWT (scope: mfa:passkey)
107+
108+
Renames the passkey identified by \`credentialId\` (base64url-encoded).
109+
The new name must be 1–255 characters and non-empty after trimming.
110+
The service validates that the passkey exists and belongs to the
111+
authenticated user. Returns 404 if the passkey is not found or is
112+
not owned by the user.
113+
114+
**Params:**
115+
- \`credentialId\` (string, required) — base64url-encoded credential ID
116+
117+
**Request body:**
118+
- \`name\` (string, required) — new display name (1–255 chars)
119+
120+
**Response:** Updated passkey metadata including \`credentialId\`, \`name\`,
121+
\`createdAt\`, \`lastUsedAt\`, \`transports\`, and \`prfEnabled\`.
122+
`,
123+
],
124+
},
66125
};
67126

68127
export default PASSKEYS_API_DOCS;

packages/fxa-auth-server/lib/metrics/glean/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,8 @@ export function gleanMetrics(config: ConfigType) {
448448
// registrationStarted: createEventFn('passkey_registration_started'),
449449
// registrationComplete: createEventFn('passkey_registration_complete'),
450450
// registrationFailed: createEventFn('passkey_registration_failed'),
451+
// deleteSuccess: createEventFn('passkey_delete_success'),
452+
// renameSuccess: createEventFn('passkey_rename_success'),
451453
// },
452454
};
453455
}

0 commit comments

Comments
 (0)