diff --git a/libs/accounts/passkey/src/lib/passkey.service.spec.ts b/libs/accounts/passkey/src/lib/passkey.service.spec.ts index 3e1e1396b78..2a948344e3b 100644 --- a/libs/accounts/passkey/src/lib/passkey.service.spec.ts +++ b/libs/accounts/passkey/src/lib/passkey.service.spec.ts @@ -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' ); @@ -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, '') diff --git a/libs/accounts/passkey/src/lib/passkey.service.ts b/libs/accounts/passkey/src/lib/passkey.service.ts index b8cc90f2978..c5926a0b93e 100644 --- a/libs/accounts/passkey/src/lib/passkey.service.ts +++ b/libs/accounts/passkey/src/lib/passkey.service.ts @@ -248,7 +248,7 @@ export class PasskeyService { uid: Buffer, credentialId: Buffer, newName: string - ): Promise { + ): Promise { const trimmed = newName.trim(); if ( !trimmed || @@ -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; } /** diff --git a/packages/fxa-auth-server/bin/key_server.js b/packages/fxa-auth-server/bin/key_server.js index 4926f786ebe..1695ad99206 100755 --- a/packages/fxa-auth-server/bin/key_server.js +++ b/packages/fxa-auth-server/bin/key_server.js @@ -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, diff --git a/packages/fxa-auth-server/config/index.ts b/packages/fxa-auth-server/config/index.ts index 6422784dee4..1a8b39bd155 100644 --- a/packages/fxa-auth-server/config/index.ts +++ b/packages/fxa-auth-server/config/index.ts @@ -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', diff --git a/packages/fxa-auth-server/docs/swagger/passkeys-api.ts b/packages/fxa-auth-server/docs/swagger/passkeys-api.ts index 4aba486a58c..82999aa4dcf 100644 --- a/packages/fxa-auth-server/docs/swagger/passkeys-api.ts +++ b/packages/fxa-auth-server/docs/swagger/passkeys-api.ts @@ -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; diff --git a/packages/fxa-auth-server/lib/metrics/glean/index.ts b/packages/fxa-auth-server/lib/metrics/glean/index.ts index 86fb7f3f893..b47a5c53d40 100644 --- a/packages/fxa-auth-server/lib/metrics/glean/index.ts +++ b/packages/fxa-auth-server/lib/metrics/glean/index.ts @@ -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'), // }, }; } diff --git a/packages/fxa-auth-server/lib/routes/account.spec.ts b/packages/fxa-auth-server/lib/routes/account.spec.ts index 0e47d43283c..1716c993250 100644 --- a/packages/fxa-auth-server/lib/routes/account.spec.ts +++ b/packages/fxa-auth-server/lib/routes/account.spec.ts @@ -28,16 +28,12 @@ const { const { AppStoreSubscriptions, } = require('../payments/iap/apple-app-store/subscriptions'); -const { - deleteAccountIfUnverified, -} = require('./utils/account'); +const { deleteAccountIfUnverified } = require('./utils/account'); const { AppConfig, AuthLogger } = require('../types'); const defaultConfig = require('../../config').default.getProperties(); const { ProfileClient } = require('@fxa/profile/client'); const { RelyingPartyConfigurationManager } = require('@fxa/shared/cms'); -const { - OAuthClientInfoServiceName, -} = require('../senders/oauth_client_info'); +const { OAuthClientInfoServiceName } = require('../senders/oauth_client_info'); const { FxaMailer } = require('../senders/fxa-mailer'); const { RecoveryPhoneService } = require('@fxa/accounts/recovery-phone'); @@ -185,7 +181,16 @@ const makeRoutes = function (options: any = {}, requireMocks: any = {}) { const signinUtils = options.signinUtils || - require('./utils/signin')(log, config, customs, db, mailer, cadReminders, glean, statsd); + require('./utils/signin')( + log, + config, + customs, + db, + mailer, + cadReminders, + glean, + statsd + ); if (options.checkPassword) { signinUtils.checkPassword = options.checkPassword; } @@ -205,7 +210,9 @@ const makeRoutes = function (options: any = {}, requireMocks: any = {}) { verificationReminders, glean ); - const pushbox = options.pushbox || { deleteAccount: jest.fn().mockResolvedValue(undefined) }; + const pushbox = options.pushbox || { + deleteAccount: jest.fn().mockResolvedValue(undefined), + }; const oauthDb = { removeTokensAndCodes: () => {}, removePublicAndCanGrantTokens: () => {}, @@ -395,9 +402,7 @@ describe('/account/reset', () => { }); it('called mailer.sendPasswordResetAccountRecoveryEmail correctly', () => { - expect( - fxaMailer.sendPasswordResetAccountRecoveryEmail.callCount - ).toBe(1); + expect(fxaMailer.sendPasswordResetAccountRecoveryEmail.callCount).toBe(1); const args = fxaMailer.sendPasswordResetAccountRecoveryEmail.args[0]; expect(args[0].to).toBe(TEST_EMAIL); }); @@ -782,7 +787,11 @@ describe('/account/create', () => { glean.registration.confirmationEmailSent.reset(); }); - function setup(extraConfig?: any, mockRequestOptsCb?: any, makeRoutesOptions: any = {}) { + function setup( + extraConfig?: any, + mockRequestOptsCb?: any, + makeRoutesOptions: any = {} + ) { const config = { securityHistory: { enabled: true, @@ -2302,10 +2311,7 @@ describe('/account/login', () => { beforeEach(() => { Container.set(AppConfig, config); Container.set(AuthLogger, mockLog); - Container.set( - AccountEventsManager, - new AccountEventsManager() - ); + Container.set(AccountEventsManager, new AccountEventsManager()); Container.set(CapabilityService, jest.fn().mockResolvedValue(undefined)); Container.set(OAuthClientInfoServiceName, mockOAuthClientInfo); Container.set(FxaMailer, mockFxaMailer); @@ -2614,9 +2620,9 @@ describe('/account/login', () => { expect(mockFxaMailer.sendVerifyLoginEmail.callCount).toBe(1); expect(mockMetricsContext.setFlowCompleteSignal.callCount).toBe(1); - expect( - mockMetricsContext.setFlowCompleteSignal.args[0][0] - ).toEqual('account.confirmed'); + expect(mockMetricsContext.setFlowCompleteSignal.args[0][0]).toEqual( + 'account.confirmed' + ); expect(response.verified).toBeFalsy(); expect(response.verificationMethod).toBe('email'); @@ -2869,7 +2875,11 @@ describe('/account/login', () => { }); describe('skip for new accounts', () => { - function setupSkipNewAccounts(enabled: any, accountCreatedSince: any, makeRoutesOptions: any = {}) { + function setupSkipNewAccounts( + enabled: any, + accountCreatedSince: any, + makeRoutesOptions: any = {} + ) { config.signinConfirmation.skipForNewAccounts = { enabled: enabled, maxAge: 5, @@ -2938,9 +2948,7 @@ describe('/account/login', () => { expect(sendVerifyLoginEmailArgs.location.country).toBe( 'United States' ); - expect(sendVerifyLoginEmailArgs.timeZone).toBe( - 'America/Los_Angeles' - ); + expect(sendVerifyLoginEmailArgs.timeZone).toBe('America/Los_Angeles'); }); }); @@ -2990,18 +2998,18 @@ describe('/account/login', () => { expect( mockFxaMailer.sendNewDeviceLoginEmail.args[0][0].deviceId ).toBe(mockRequest.payload.metricsContext.deviceId); - expect( - mockFxaMailer.sendNewDeviceLoginEmail.args[0][0].flowId - ).toBe(mockRequest.payload.metricsContext.flowId); + expect(mockFxaMailer.sendNewDeviceLoginEmail.args[0][0].flowId).toBe( + mockRequest.payload.metricsContext.flowId + ); expect( mockFxaMailer.sendNewDeviceLoginEmail.args[0][0].flowBeginTime ).toBe(mockRequest.payload.metricsContext.flowBeginTime); - expect( - mockFxaMailer.sendNewDeviceLoginEmail.args[0][0].sync - ).toBe(true); - expect( - mockFxaMailer.sendNewDeviceLoginEmail.args[0][0].uid - ).toBe(uid); + expect(mockFxaMailer.sendNewDeviceLoginEmail.args[0][0].sync).toBe( + true + ); + expect(mockFxaMailer.sendNewDeviceLoginEmail.args[0][0].uid).toBe( + uid + ); expect(response.emailVerified).toBeTruthy(); }); }); @@ -3386,17 +3394,21 @@ describe('/account/login', () => { }, }; - return runTest(route, requestWithDifferentUserAgent, (response: any) => { - expect(mockDB.createSessionToken.callCount).toBe(1); - const tokenData = mockDB.createSessionToken.getCall(0).args[0]; - expect(tokenData.mustVerify).toBeTruthy(); - expect(response.verified).toBeFalsy(); - - sinon.assert.calledWith( - statsd.increment, - 'account.signin.confirm.device.notfound' - ); - }); + return runTest( + route, + requestWithDifferentUserAgent, + (response: any) => { + expect(mockDB.createSessionToken.callCount).toBe(1); + const tokenData = mockDB.createSessionToken.getCall(0).args[0]; + expect(tokenData.mustVerify).toBeTruthy(); + expect(response.verified).toBeFalsy(); + + sinon.assert.calledWith( + statsd.increment, + 'account.signin.confirm.device.notfound' + ); + } + ); }); it('should not skip verification when in report-only mode', () => { @@ -3635,7 +3647,9 @@ describe('/account/login', () => { it('invalid code', async () => { mockDB.consumeUnblockCode = () => Promise.reject(error.invalidUnblockCode()); - await expect(runTest(route, mockRequestWithUnblockCode)).rejects.toMatchObject({ + await expect( + runTest(route, mockRequestWithUnblockCode) + ).rejects.toMatchObject({ errno: error.ERRNO.INVALID_UNBLOCK_CODE, output: { statusCode: 400 }, }); @@ -3653,7 +3667,9 @@ describe('/account/login', () => { createdAt: Date.now() - (config.signinUnblock.codeLifetime + 5000), }); - await expect(runTest(route, mockRequestWithUnblockCode)).rejects.toMatchObject({ + await expect( + runTest(route, mockRequestWithUnblockCode) + ).rejects.toMatchObject({ errno: error.ERRNO.INVALID_UNBLOCK_CODE, output: { statusCode: 400 }, }); @@ -3668,7 +3684,9 @@ describe('/account/login', () => { it('unknown account', async () => { mockDB.accountRecord = () => Promise.reject(error.unknownAccount()); mockDB.emailRecord = () => Promise.reject(error.unknownAccount()); - await expect(runTest(route, mockRequestWithUnblockCode)).rejects.toMatchObject({ + await expect( + runTest(route, mockRequestWithUnblockCode) + ).rejects.toMatchObject({ errno: error.ERRNO.REQUEST_BLOCKED, output: { statusCode: 400 }, }); @@ -3685,12 +3703,8 @@ describe('/account/login', () => { expect(mockLog.flowEvent.args[1][0].event).toBe( 'account.login.confirmedUnblockCode' ); - expect(mockLog.flowEvent.args[2][0].event).toBe( - 'account.login' - ); - expect(mockLog.flowEvent.args[3][0].event).toBe( - 'flow.complete' - ); + expect(mockLog.flowEvent.args[2][0].event).toBe('account.login'); + expect(mockLog.flowEvent.args[3][0].event).toBe('flow.complete'); }); }); }); @@ -3746,7 +3760,9 @@ describe('/account/login', () => { }); }); return runTest(route, mockRequest).then( - () => { throw new Error('should have thrown'); }, + () => { + throw new Error('should have thrown'); + }, (err: any) => { expect(mockDB.accountRecord.callCount).toBe(1); expect(err.errno).toBe(142); @@ -3763,7 +3779,9 @@ describe('/account/login', () => { }); mockRequest.payload.verificationMethod = 'totp-2fa'; return runTest(route, mockRequest).then( - () => { throw new Error('should have thrown'); }, + () => { + throw new Error('should have thrown'); + }, (err: any) => { expect(mockDB.totpToken.callCount).toBe(1); expect(err.errno).toBe(160); @@ -3839,8 +3857,12 @@ describe('/account/login', () => { expect(emailMessage.cmsRpFromName).toBe('Testo Inc.'); expect(emailMessage.entrypoint).toBe('testo'); expect(emailMessage.logoUrl).toBe('http://img.exmpl.gg/logo.svg'); - expect(emailMessage.subject).toBe(rpCmsConfig.NewDeviceLoginEmail.subject); - expect(emailMessage.headline).toBe(rpCmsConfig.NewDeviceLoginEmail.headline); + expect(emailMessage.subject).toBe( + rpCmsConfig.NewDeviceLoginEmail.subject + ); + expect(emailMessage.headline).toBe( + rpCmsConfig.NewDeviceLoginEmail.headline + ); expect(emailMessage.description).toBe( rpCmsConfig.NewDeviceLoginEmail.description ); @@ -3905,7 +3927,9 @@ describe('/account/keys', () => { mockRequest.auth.credentials.tokenVerified = false; return runTest(route, mockRequest) .then( - () => { throw new Error('should have thrown'); }, + () => { + throw new Error('should have thrown'); + }, (response: any) => { expect(response.errno).toBe(104); expect(response.message).toBe('Unconfirmed account'); @@ -3922,7 +3946,12 @@ describe('/account/destroy', () => { const tokenVerified = true; const uid = uuid.v4({}, Buffer.alloc(16)).toString('hex'); - let mockDB: any, mockLog: any, mockRequest: any, mockPush: any, mockPushbox: any, mockCustoms: any; + let mockDB: any, + mockLog: any, + mockRequest: any, + mockPush: any, + mockPushbox: any, + mockCustoms: any; beforeEach(async () => { mockDB = { @@ -3990,9 +4019,13 @@ describe('/account/destroy', () => { customerId: 'customer123', reason: ReasonForDeletion.UserRequested, }); - sinon.assert.calledOnceWithExactly(glean.account.deleteComplete, mockRequest, { - uid, - }); + sinon.assert.calledOnceWithExactly( + glean.account.deleteComplete, + mockRequest, + { + uid, + } + ); sinon.assert.calledOnceWithExactly( mockLog.info, 'accountDeleted.ByRequest', @@ -4005,7 +4038,9 @@ describe('/account/destroy', () => { const route = buildRoute(); // Here we act like there's an error when calling accountDeleteManager.quickDelete(...) - mockAccountQuickDelete = jest.fn().mockRejectedValue(new Error('quickDelete failed')); + mockAccountQuickDelete = jest + .fn() + .mockRejectedValue(new Error('quickDelete failed')); return runTest(route, mockRequest, () => { sinon.assert.calledOnceWithExactly(mockDB.accountRecord, email); @@ -4014,9 +4049,13 @@ describe('/account/destroy', () => { customerId: 'customer123', reason: ReasonForDeletion.UserRequested, }); - sinon.assert.calledOnceWithExactly(glean.account.deleteComplete, mockRequest, { - uid, - }); + sinon.assert.calledOnceWithExactly( + glean.account.deleteComplete, + mockRequest, + { + uid, + } + ); }); }); @@ -4043,9 +4082,13 @@ describe('/account/destroy', () => { customerId: 'customer123', reason: ReasonForDeletion.UserRequested, }); - sinon.assert.calledOnceWithExactly(glean.account.deleteComplete, mockRequest, { - uid, - }); + sinon.assert.calledOnceWithExactly( + glean.account.deleteComplete, + mockRequest, + { + uid, + } + ); }); }); @@ -4149,7 +4192,9 @@ describe('/account', () => { mockStripeHelper.subscriptionsToResponse = sinon.spy( async (subscriptions: any) => mockWebSubscriptionsResponse ); - mockStripeHelper.removeFirestoreCustomer = jest.fn().mockResolvedValue(undefined); + mockStripeHelper.removeFirestoreCustomer = jest + .fn() + .mockResolvedValue(undefined); Container.set(CapabilityService, jest.fn()); }); @@ -4343,7 +4388,9 @@ describe('/account', () => { }); it('should return an empty list when no active Google Play or web subscriptions are found', () => { - mockPlaySubscriptions.getSubscriptions = sinon.spy(async (uid: any) => []); + mockPlaySubscriptions.getSubscriptions = sinon.spy( + async (uid: any) => [] + ); return runTest( buildRoute(subscriptionsEnabled, playSubscriptionsEnabled), @@ -4434,9 +4481,9 @@ describe('/account', () => { 'getSubscriptions', ]); Container.set(AppStoreSubscriptions, mockAppStoreSubscriptions); - mockAppStoreSubscriptions.getSubscriptions = sinon.spy(async (uid: any) => [ - mockAppendedAppStoreSubscriptionPurchase, - ]); + mockAppStoreSubscriptions.getSubscriptions = sinon.spy( + async (uid: any) => [mockAppendedAppStoreSubscriptionPurchase] + ); }); it('should return formatted Apple App Store subscriptions when App Store subscriptions are enabled', () => { @@ -4487,7 +4534,9 @@ describe('/account', () => { }); it('should return an empty list when no active Apple App Store or web subscriptions are found', () => { - mockAppStoreSubscriptions.getSubscriptions = sinon.spy(async (uid: any) => []); + mockAppStoreSubscriptions.getSubscriptions = sinon.spy( + async (uid: any) => [] + ); return runTest( buildRoute(subscriptionsEnabled, false, appStoreSubscriptionsEnabled), @@ -4531,15 +4580,27 @@ describe('/account', () => { describe('expanded account data fields', () => { it('should return account metadata and 2FA status', () => { return runTest(buildRoute(), request, (result: any) => { - expect(Object.prototype.hasOwnProperty.call(result, 'createdAt')).toBeTruthy(); + expect( + Object.prototype.hasOwnProperty.call(result, 'createdAt') + ).toBeTruthy(); expect( Object.prototype.hasOwnProperty.call(result, 'passwordCreatedAt') ).toBeTruthy(); - expect(Object.prototype.hasOwnProperty.call(result, 'hasPassword')).toBeTruthy(); - expect(Object.prototype.hasOwnProperty.call(result, 'emails')).toBeTruthy(); - expect(Object.prototype.hasOwnProperty.call(result, 'totp')).toBeTruthy(); - expect(Object.prototype.hasOwnProperty.call(result, 'backupCodes')).toBeTruthy(); - expect(Object.prototype.hasOwnProperty.call(result, 'recoveryKey')).toBeTruthy(); + expect( + Object.prototype.hasOwnProperty.call(result, 'hasPassword') + ).toBeTruthy(); + expect( + Object.prototype.hasOwnProperty.call(result, 'emails') + ).toBeTruthy(); + expect( + Object.prototype.hasOwnProperty.call(result, 'totp') + ).toBeTruthy(); + expect( + Object.prototype.hasOwnProperty.call(result, 'backupCodes') + ).toBeTruthy(); + expect( + Object.prototype.hasOwnProperty.call(result, 'recoveryKey') + ).toBeTruthy(); expect( Object.prototype.hasOwnProperty.call(result, 'recoveryPhone') ).toBeTruthy(); @@ -4593,7 +4654,9 @@ describe('/account', () => { allowedRegions: ['US'], }); Container.set(RecoveryPhoneService, { - hasConfirmed: jest.fn().mockResolvedValue({ exists: false, phoneNumber: null }), + hasConfirmed: jest + .fn() + .mockResolvedValue({ exists: false, phoneNumber: null }), available: jest.fn().mockResolvedValue(false), }); return runTest(route, request, (result: any) => { @@ -4607,7 +4670,9 @@ describe('/account', () => { allowedRegions: ['US'], }); Container.set(RecoveryPhoneService, { - hasConfirmed: jest.fn().mockResolvedValue({ exists: false, phoneNumber: null }), + hasConfirmed: jest + .fn() + .mockResolvedValue({ exists: false, phoneNumber: null }), available: jest.fn().mockRejectedValue(new Error('service error')), }); return runTest(route, request, (result: any) => { @@ -4626,7 +4691,9 @@ describe('/account', () => { allowedRegions: ['US'], }); Container.set(RecoveryPhoneService, { - hasConfirmed: jest.fn().mockResolvedValue({ exists: false, phoneNumber: null }), + hasConfirmed: jest + .fn() + .mockResolvedValue({ exists: false, phoneNumber: null }), available: jest.fn().mockResolvedValue(false), }); return runTest(route, noGeoRequest, (result: any) => { @@ -4634,6 +4701,83 @@ describe('/account', () => { }); }); }); + + describe('passkeys', () => { + const { PasskeyService } = require('@fxa/accounts/passkey'); + + const mockPasskey = { + credentialId: Buffer.from('cred-id'), + name: 'My Passkey', + createdAt: 1000000, + lastUsedAt: 2000000, + transports: ['internal'], + aaguid: Buffer.from('aaguid12345678ab'), + backupEligible: true, + backupState: false, + prfEnabled: true, + }; + + function buildPasskeysRoute( + passkeyServiceMock: any, + passkeysEnabled = true + ) { + Container.set(PasskeyService, passkeyServiceMock); + const accountRoutes = makeRoutes({ + config: { + subscriptions: { enabled: false }, + passkeys: { enabled: passkeysEnabled }, + }, + log: log, + db: mocks.mockDB({ email, uid }), + }); + return getRoute(accountRoutes, '/account'); + } + + it('includes passkeys with prfEnabled when feature flag is enabled', async () => { + const mockService = { + listPasskeysForUser: jest.fn().mockResolvedValue([mockPasskey]), + }; + const route = buildPasskeysRoute(mockService); + const result: any = await runTest(route, request); + + expect(mockService.listPasskeysForUser).toHaveBeenCalledWith( + Buffer.from(uid) + ); + expect(result.passkeys).toHaveLength(1); + expect(result.passkeys[0]).toEqual({ + credentialId: mockPasskey.credentialId.toString('base64url'), + name: mockPasskey.name, + createdAt: mockPasskey.createdAt, + lastUsedAt: mockPasskey.lastUsedAt, + transports: mockPasskey.transports, + aaguid: mockPasskey.aaguid.toString('base64url'), + backupEligible: mockPasskey.backupEligible, + backupState: mockPasskey.backupState, + prfEnabled: mockPasskey.prfEnabled, + }); + }); + + it('returns empty passkeys array when feature flag is disabled', async () => { + const mockService = { + listPasskeysForUser: jest.fn().mockResolvedValue([mockPasskey]), + }; + const route = buildPasskeysRoute(mockService, false); + const result: any = await runTest(route, request); + + expect(mockService.listPasskeysForUser).not.toHaveBeenCalled(); + expect(result.passkeys).toEqual([]); + }); + + it('returns empty passkeys array when PasskeyService rejects', async () => { + const mockService = { + listPasskeysForUser: jest.fn().mockRejectedValue(new Error('db error')), + }; + const route = buildPasskeysRoute(mockService); + const result: any = await runTest(route, request); + + expect(result.passkeys).toEqual([]); + }); + }); }); describe('/account/email_bounce_status', () => { diff --git a/packages/fxa-auth-server/lib/routes/account.ts b/packages/fxa-auth-server/lib/routes/account.ts index 5503493ca18..e0d8eb2870a 100644 --- a/packages/fxa-auth-server/lib/routes/account.ts +++ b/packages/fxa-auth-server/lib/routes/account.ts @@ -68,6 +68,8 @@ import { FxaMailerFormat } from '../senders/fxa-mailer-format'; import { OAuthClientInfoServiceName } from '../senders/oauth_client_info'; import { BackupCodeManager } from '@fxa/accounts/two-factor'; import { RecoveryPhoneService } from '@fxa/accounts/recovery-phone'; +import { PasskeyService } from '@fxa/accounts/passkey'; +import type { Passkey } from '@fxa/shared/db/mysql/account'; import { BOUNCE_TYPE_HARD } from '@fxa/accounts/email-sender'; import { getClientServiceTags } from '../metrics/client-tags'; @@ -2420,6 +2422,7 @@ export class AccountHandler { securityEventsResult, devicesResult, authorizedClientsResult, + passkeysResult, ] = await Promise.allSettled([ this.db.account(uid), this.db.accountEmails(uid), @@ -2437,6 +2440,9 @@ export class AccountHandler { this.db.securityEventsByUid({ uid }), this.db.devices(uid), listAuthorizedClients(uid), + this.config.passkeys?.enabled + ? Container.get(PasskeyService).listPasskeysForUser(Buffer.from(uid)) + : Promise.resolve([]), ]); const recoveryPhoneAvailable = @@ -2534,6 +2540,33 @@ export class AccountHandler { ) : []; + const passkeys = + passkeysResult.status === 'fulfilled' + ? (passkeysResult.value as Passkey[]).map( + ({ + credentialId, + name, + createdAt, + lastUsedAt, + transports, + aaguid, + backupEligible, + backupState, + prfEnabled, + }) => ({ + credentialId: credentialId.toString('base64url'), + name, + createdAt, + lastUsedAt, + transports, + aaguid: aaguid.toString('base64url'), + backupEligible, + backupState, + prfEnabled, + }) + ) + : []; + // Fetch subscriptions (separate block due to complexity) let webSubscriptions: Awaited = []; let iapGooglePlaySubscriptions: Awaited = []; @@ -2571,23 +2604,18 @@ export class AccountHandler { } return { - // Account metadata createdAt: account.createdAt, passwordCreatedAt: account.verifierSetAt, metricsOptOutAt: account.metricsOptOutAt, hasPassword: account.verifierSetAt > 0, - // Emails emails: formattedEmails, - // Linked accounts linkedAccounts, - // 2FA status totp, backupCodes, recoveryKey, recoveryPhone, - // Security events securityEvents, - // Subscriptions + passkeys, subscriptions: [ ...iapGooglePlaySubscriptions, ...iapAppStoreSubscriptions, @@ -3224,6 +3252,22 @@ export const accountRoutes = ( }) ) .optional(), + passkeys: isA + .array() + .items( + isA.object({ + credentialId: isA.string().required(), + name: isA.string().required(), + createdAt: isA.number().required(), + lastUsedAt: isA.number().allow(null).required(), + transports: isA.array().items(isA.string()).required(), + aaguid: isA.string().required(), + backupEligible: isA.boolean().required(), + backupState: isA.boolean().required(), + prfEnabled: isA.boolean().required(), + }) + ) + .optional(), subscriptions: isA .array() .items( diff --git a/packages/fxa-auth-server/lib/routes/passkeys.spec.ts b/packages/fxa-auth-server/lib/routes/passkeys.spec.ts index d5cb2b5f503..530a5536d2c 100644 --- a/packages/fxa-auth-server/lib/routes/passkeys.spec.ts +++ b/packages/fxa-auth-server/lib/routes/passkeys.spec.ts @@ -27,6 +27,8 @@ describe('passkeys routes', () => { const UID = 'uid-123'; const SESSION_TOKEN_ID = 'session-token-456'; const TEST_EMAIL = 'test@example.com'; + const CREDENTIAL_ID_B64 = + Buffer.from('credential-id-xyz').toString('base64url'); const config = { passkeys: { @@ -43,12 +45,18 @@ describe('passkeys routes', () => { attestation: 'none', }; - const mockPasskey = { - credentialId: 'credential-id-xyz', + const mockPasskeyRecord = { + credentialId: Buffer.from('credential-id-xyz'), name: 'My Passkey', createdAt: Date.now(), lastUsedAt: Date.now(), transports: ['internal'], + publicKey: Buffer.from('public-key'), + signCount: 42, + aaguid: Buffer.from('aaguid12345678ab'), + backupEligible: true, + backupState: false, + prfEnabled: true, }; async function runTest( @@ -91,7 +99,10 @@ describe('passkeys routes', () => { .mockResolvedValue(mockRegistrationOptions), createPasskeyFromRegistrationResponse: jest .fn() - .mockResolvedValue(mockPasskey), + .mockResolvedValue(mockPasskeyRecord), + listPasskeysForUser: jest.fn().mockResolvedValue([mockPasskeyRecord]), + deletePasskey: jest.fn().mockResolvedValue(undefined), + renamePasskey: jest.fn().mockResolvedValue(mockPasskeyRecord), }; Container.set(PasskeyService, mockPasskeyService); @@ -172,21 +183,6 @@ describe('passkeys routes', () => { }) ).rejects.toThrow('Client has sent too many requests'); }); - - it('can be disabled', async () => { - config.passkeys.enabled = false; - await expect(() => - runTest('/passkey/registration/start', { - auth: { - credentials: { - uid: UID, - id: SESSION_TOKEN_ID, - email: TEST_EMAIL, - }, - }, - }) - ).rejects.toThrow('System unavailable, try again soon'); - }); }); describe('POST /passkey/registration/finish', () => { @@ -209,9 +205,9 @@ describe('passkeys routes', () => { expect(result).toEqual( expect.objectContaining({ - credentialId: mockPasskey.credentialId, - name: mockPasskey.name, - transports: mockPasskey.transports, + credentialId: mockPasskeyRecord.credentialId.toString('base64url'), + name: mockPasskeyRecord.name, + transports: mockPasskeyRecord.transports, lastUsedAt: expect.any(Number), createdAt: expect.any(Number), }) @@ -292,12 +288,159 @@ describe('passkeys routes', () => { 'passkeyRegisterFinish' ); }); + }); + + describe('GET /passkeys', () => { + it('returns mapped passkeys', async () => { + const result = await runTest( + '/passkeys', + { + auth: { + credentials: { + uid: UID, + id: SESSION_TOKEN_ID, + email: TEST_EMAIL, + }, + }, + }, + 'GET' + ); + + expect(mockPasskeyService.listPasskeysForUser).toHaveBeenCalledWith( + Buffer.from(UID) + ); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + credentialId: mockPasskeyRecord.credentialId.toString('base64url'), + name: mockPasskeyRecord.name, + createdAt: mockPasskeyRecord.createdAt, + lastUsedAt: mockPasskeyRecord.lastUsedAt, + transports: mockPasskeyRecord.transports, + aaguid: mockPasskeyRecord.aaguid.toString('base64url'), + backupEligible: mockPasskeyRecord.backupEligible, + backupState: mockPasskeyRecord.backupState, + prfEnabled: mockPasskeyRecord.prfEnabled, + }); + expect(result[0]).not.toHaveProperty('publicKey'); + expect(result[0]).not.toHaveProperty('signCount'); + }); + + it('returns an empty array when user has no passkeys', async () => { + mockPasskeyService.listPasskeysForUser.mockResolvedValueOnce([]); + + const result = await runTest( + '/passkeys', + { + auth: { + credentials: { + uid: UID, + id: SESSION_TOKEN_ID, + email: TEST_EMAIL, + }, + }, + }, + 'GET' + ); + + expect(result).toEqual([]); + }); + + it('enforces rate limiting via customs.checkAuthenticated', async () => { + await runTest( + '/passkeys', + { + auth: { + credentials: { + uid: UID, + id: SESSION_TOKEN_ID, + email: TEST_EMAIL, + }, + }, + }, + 'GET' + ); + + expect(customs.checkAuthenticated).toHaveBeenCalledWith( + expect.anything(), + UID, + TEST_EMAIL, + 'passkeysList' + ); + }); + }); + + describe('DELETE /passkey/{credentialId}', () => { + it('decodes credentialId and calls deletePasskey', async () => { + await runTest( + '/passkey/{credentialId}', + { + auth: { + credentials: { + uid: UID, + id: SESSION_TOKEN_ID, + email: TEST_EMAIL, + }, + }, + params: { credentialId: CREDENTIAL_ID_B64 }, + }, + 'DELETE' + ); + + expect(mockPasskeyService.deletePasskey).toHaveBeenCalledWith( + Buffer.from(UID), + Buffer.from(CREDENTIAL_ID_B64, 'base64url') + ); + }); + + it('records a security event on success', async () => { + await runTest( + '/passkey/{credentialId}', + { + auth: { + credentials: { + uid: UID, + id: SESSION_TOKEN_ID, + email: TEST_EMAIL, + }, + }, + params: { credentialId: CREDENTIAL_ID_B64 }, + }, + 'DELETE' + ); + + expect(recordSecurityEvent).toHaveBeenCalledWith( + 'account.passkey.removed', + expect.anything() + ); + }); - it('can be disabled', async () => { - config.passkeys.enabled = false; + it('throws passkeyNotFound when service throws passkeyNotFound', async () => { + mockPasskeyService.deletePasskey.mockRejectedValue( + AppError.passkeyNotFound() + ); await expect(() => - runTest('/passkey/registration/finish', { + runTest( + '/passkey/{credentialId}', + { + auth: { + credentials: { + uid: UID, + id: SESSION_TOKEN_ID, + email: TEST_EMAIL, + }, + }, + params: { credentialId: CREDENTIAL_ID_B64 }, + }, + 'DELETE' + ) + ).rejects.toThrow(); + }); + + it('returns empty object on success', async () => { + const result = await runTest( + '/passkey/{credentialId}', + { auth: { credentials: { uid: UID, @@ -305,9 +448,141 @@ describe('passkeys routes', () => { email: TEST_EMAIL, }, }, - payload, - }) - ).rejects.toThrow('System unavailable, try again soon'); + params: { credentialId: CREDENTIAL_ID_B64 }, + }, + 'DELETE' + ); + + expect(result).toEqual({}); + }); + + it('enforces rate limiting via customs.checkAuthenticated', async () => { + await runTest( + '/passkey/{credentialId}', + { + auth: { + credentials: { + uid: UID, + id: SESSION_TOKEN_ID, + email: TEST_EMAIL, + }, + }, + params: { credentialId: CREDENTIAL_ID_B64 }, + }, + 'DELETE' + ); + + expect(customs.checkAuthenticated).toHaveBeenCalledWith( + expect.anything(), + UID, + TEST_EMAIL, + 'passkeyDelete' + ); + }); + }); + + describe('PATCH /passkey/{credentialId}', () => { + it('decodes credentialId and calls renamePasskey', async () => { + await runTest( + '/passkey/{credentialId}', + { + auth: { + credentials: { + uid: UID, + id: SESSION_TOKEN_ID, + email: TEST_EMAIL, + }, + }, + params: { credentialId: CREDENTIAL_ID_B64 }, + payload: { name: 'Renamed Key' }, + }, + 'PATCH' + ); + + expect(mockPasskeyService.renamePasskey).toHaveBeenCalledWith( + Buffer.from(UID), + Buffer.from(CREDENTIAL_ID_B64, 'base64url'), + 'Renamed Key' + ); + }); + + it('returns updated passkey data on success', async () => { + const result = await runTest( + '/passkey/{credentialId}', + { + auth: { + credentials: { + uid: UID, + id: SESSION_TOKEN_ID, + email: TEST_EMAIL, + }, + }, + params: { credentialId: CREDENTIAL_ID_B64 }, + payload: { name: 'Renamed Key' }, + }, + 'PATCH' + ); + + expect(result).toEqual({ + credentialId: mockPasskeyRecord.credentialId.toString('base64url'), + name: mockPasskeyRecord.name, + createdAt: mockPasskeyRecord.createdAt, + lastUsedAt: mockPasskeyRecord.lastUsedAt, + transports: mockPasskeyRecord.transports, + aaguid: mockPasskeyRecord.aaguid.toString('base64url'), + backupEligible: mockPasskeyRecord.backupEligible, + backupState: mockPasskeyRecord.backupState, + prfEnabled: mockPasskeyRecord.prfEnabled, + }); + }); + + it('throws passkeyNotFound when service throws passkeyNotFound', async () => { + mockPasskeyService.renamePasskey.mockRejectedValue( + AppError.passkeyNotFound() + ); + + await expect(() => + runTest( + '/passkey/{credentialId}', + { + auth: { + credentials: { + uid: UID, + id: SESSION_TOKEN_ID, + email: TEST_EMAIL, + }, + }, + params: { credentialId: CREDENTIAL_ID_B64 }, + payload: { name: 'New Name' }, + }, + 'PATCH' + ) + ).rejects.toThrow(); + }); + + it('enforces rate limiting via customs.checkAuthenticated', async () => { + await runTest( + '/passkey/{credentialId}', + { + auth: { + credentials: { + uid: UID, + id: SESSION_TOKEN_ID, + email: TEST_EMAIL, + }, + }, + params: { credentialId: CREDENTIAL_ID_B64 }, + payload: { name: 'Renamed Key' }, + }, + 'PATCH' + ); + + expect(customs.checkAuthenticated).toHaveBeenCalledWith( + expect.anything(), + UID, + TEST_EMAIL, + 'passkeysRename' + ); }); }); }); diff --git a/packages/fxa-auth-server/lib/routes/passkeys.ts b/packages/fxa-auth-server/lib/routes/passkeys.ts index 3b96907147e..506aee02ae5 100644 --- a/packages/fxa-auth-server/lib/routes/passkeys.ts +++ b/packages/fxa-auth-server/lib/routes/passkeys.ts @@ -12,7 +12,6 @@ import { isPasskeyFeatureEnabled } from '../passkey-utils'; import { GleanMetricsType } from '../metrics/glean'; import PASSKEYS_API_DOCS from '../../docs/swagger/passkeys-api'; import { RegistrationResponseJSON } from '@simplewebauthn/server'; -import { AppError } from '@fxa/accounts/errors'; /** Subset of the Customs service used by passkey routes. */ interface Customs { @@ -37,7 +36,8 @@ interface DB { } /** - * Route handler class that encapsulates the WebAuthn registration flow. + * Route handler class that encapsulates the WebAuthn registration flow + * and passkey management operations. * * Each method corresponds to one HTTP endpoint and is responsible for: * - Feature-flag gating @@ -64,12 +64,6 @@ class PasskeyHandler { * @returns WebAuthn registration options to pass to `navigator.credentials.create`. */ async registrationStart(request: AuthRequest) { - if (!this.service.enabled) { - throw AppError.backendServiceFailure('passkey', 'registrationStart', { - reason: 'Service disabled', - }); - } - const { uid } = request.auth.credentials as { uid: string; }; @@ -107,12 +101,6 @@ class PasskeyHandler { * `createdAt`, `lastUsedAt`, and `transports`. */ async registrationFinish(request: AuthRequest) { - if (!this.service.enabled) { - throw AppError.backendServiceFailure('passkey', 'registrationFinished', { - reason: 'Service disabled', - }); - } - const { uid } = request.auth.credentials as { uid: string; }; @@ -146,7 +134,13 @@ class PasskeyHandler { // await this.glean.passkey.registrationComplete(request); const { credentialId, name, createdAt, lastUsedAt, transports } = passkey; - return { credentialId, name, createdAt, lastUsedAt, transports }; + return { + credentialId: credentialId.toString('base64url'), + name, + createdAt, + lastUsedAt, + transports, + }; } catch (err) { await recordSecurityEvent('account.passkey.registration_failure', { db: this.db, @@ -159,6 +153,134 @@ class PasskeyHandler { throw err; } } + + /** + * Handles `GET /passkeys`. + * + * Lists all passkeys registered for the authenticated user. + * + * @param request - Authenticated Hapi request with a valid session token. + * @returns Array of passkey metadata objects. + */ + async listPasskeys(request: AuthRequest) { + const { uid } = request.auth.credentials as { uid: string }; + + const account = await this.db.account(uid); + await this.customs.checkAuthenticated( + request, + uid, + account.email, + 'passkeysList' + ); + + const passkeys = await this.service.listPasskeysForUser(Buffer.from(uid)); + + // omit publicKey and signCount + return passkeys.map( + ({ + credentialId, + name, + createdAt, + lastUsedAt, + transports, + aaguid, + backupEligible, + backupState, + prfEnabled, + }) => ({ + credentialId: credentialId.toString('base64url'), + name, + createdAt, + lastUsedAt, + transports, + aaguid: aaguid.toString('base64url'), + backupEligible, + backupState, + prfEnabled, + }) + ); + } + + /** + * Handles `DELETE /passkey/:credentialId`. + * + * Deletes the passkey with `credentialId`. + * + * @param request - Authenticated Hapi request with a valid MFA JWT. + */ + async deletePasskey(request: AuthRequest) { + const { uid } = request.auth.credentials as { uid: string }; + const { credentialId: credentialIdParam } = request.params as { + credentialId: string; + }; + + const account = await this.db.account(uid); + await this.customs.checkAuthenticated( + request, + uid, + account.email, + 'passkeyDelete' + ); + + const credentialId = Buffer.from(credentialIdParam, 'base64url'); + + await this.service.deletePasskey(Buffer.from(uid), credentialId); + + await recordSecurityEvent('account.passkey.removed', { + db: this.db, + request, + }); + + // TODO: FXA-12914 — Glean event name needs to be defined in the Glean schema + // await this.glean.passkey.deleteSuccess(request, { uid }); + + return {}; + } + + /** + * Handles `PATCH /passkey/:credentialId`. + * + * @param request - Authenticated Hapi request with a valid MFA JWT. + * @returns Updated passkey metadata object. + */ + async renamePasskey(request: AuthRequest) { + const { uid } = request.auth.credentials as { uid: string }; + const { credentialId: credentialIdParam } = request.params as { + credentialId: string; + }; + const { name } = request.payload as { name: string }; + + const account = await this.db.account(uid); + await this.customs.checkAuthenticated( + request, + uid, + account.email, + 'passkeysRename' + ); + + const credentialId = Buffer.from(credentialIdParam, 'base64url'); + + const passkey = await this.service.renamePasskey( + Buffer.from(uid), + credentialId, + name + ); + + // TODO: FXA-12914 — Glean event name needs to be defined in the Glean schema + // await this.glean.passkey.renameSuccess(request, { uid }); + + return { + credentialId: passkey.credentialId.toString('base64url'), + name: passkey.name, + createdAt: passkey.createdAt, + lastUsedAt: passkey.lastUsedAt, + transports: passkey.transports, + aaguid: passkey.aaguid.toString('base64url'), + backupEligible: passkey.backupEligible, + backupState: passkey.backupState, + prfEnabled: passkey.prfEnabled, + }; + } } /** @@ -340,6 +462,10 @@ export const passkeyRoutes = ( createdAt: isA.number().required(), lastUsedAt: isA.number().required(), transports: isA.array().items(isA.string()).required(), + aaguid: isA.string().required(), + backupEligible: isA.boolean().required(), + backupState: isA.boolean().required(), + prfEnabled: isA.boolean().required(), }), }, }, @@ -348,6 +474,100 @@ export const passkeyRoutes = ( return handler.registrationFinish(request); }, }, + { + method: 'GET', + path: '/passkeys', + options: { + ...PASSKEYS_API_DOCS.PASSKEYS_GET, + pre: [{ method: featureEnabledCheck }], + auth: { + strategy: 'verifiedSessionToken', + payload: false, + }, + response: { + schema: isA.array().items( + isA.object({ + credentialId: isA.string().required(), + name: isA.string().required(), + createdAt: isA.number().required(), + lastUsedAt: isA.number().allow(null).required(), + transports: isA.array().items(isA.string()).required(), + aaguid: isA.string().required(), + backupEligible: isA.boolean().required(), + backupState: isA.boolean().required(), + prfEnabled: isA.boolean().required(), + }) + ), + }, + }, + handler: function (request: AuthRequest) { + log.begin('passkey.list', request); + return handler.listPasskeys(request); + }, + }, + { + method: 'DELETE', + path: '/passkey/{credentialId}', + options: { + ...PASSKEYS_API_DOCS.PASSKEY_CREDENTIAL_DELETE, + pre: [{ method: featureEnabledCheck }], + auth: { + strategy: 'mfa', + scope: ['mfa:passkey'], + payload: false, + }, + validate: { + params: isA.object({ + credentialId: isA.string().required(), + }), + }, + response: { + schema: isA.object({}), + }, + }, + handler: function (request: AuthRequest) { + log.begin('passkey.delete', request); + return handler.deletePasskey(request); + }, + }, + { + method: 'PATCH', + path: '/passkey/{credentialId}', + options: { + ...PASSKEYS_API_DOCS.PASSKEY_CREDENTIAL_PATCH, + pre: [{ method: featureEnabledCheck }], + auth: { + strategy: 'mfa', + scope: ['mfa:passkey'], + payload: false, + }, + validate: { + params: isA.object({ + credentialId: isA.string().required(), + }), + payload: isA.object({ + name: isA.string().min(1).max(255).required(), + }), + }, + response: { + schema: isA.object({ + credentialId: isA.string().required(), + name: isA.string().required(), + createdAt: isA.number().required(), + lastUsedAt: isA.number().allow(null).required(), + transports: isA.array().items(isA.string()).required(), + aaguid: isA.string().required(), + backupEligible: isA.boolean().required(), + backupState: isA.boolean().required(), + prfEnabled: isA.boolean().required(), + }), + }, + }, + handler: function (request: AuthRequest) { + log.begin('passkey.rename', request); + return handler.renamePasskey(request); + }, + }, ]; }; diff --git a/packages/fxa-settings/src/components/Settings/SubRow/index.tsx b/packages/fxa-settings/src/components/Settings/SubRow/index.tsx index fdb0d95e3fc..25733106782 100644 --- a/packages/fxa-settings/src/components/Settings/SubRow/index.tsx +++ b/packages/fxa-settings/src/components/Settings/SubRow/index.tsx @@ -464,7 +464,7 @@ export const PasskeySubRow = ({ /> {deleteModalRevealed && ( { hideDeleteModal(); }} diff --git a/packages/fxa-settings/src/lib/types.ts b/packages/fxa-settings/src/lib/types.ts index 73aeb6a945d..d84ee66a467 100644 --- a/packages/fxa-settings/src/lib/types.ts +++ b/packages/fxa-settings/src/lib/types.ts @@ -102,4 +102,4 @@ export type MfaScope = | 'email' | 'recovery_key' | 'password' - | 'passkeys'; + | 'passkey';