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
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ describe('PasskeyRepository (Integration)', () => {
});

describe('update operations', () => {
it('should update counter and lastUsed after authentication', async () => {
it('should update counter and lastUsedAt after authentication', async () => {
const uid = await createTestAccount();
const passkey = PasskeyFactory({ uid });
await PasskeyRepository.insertPasskey(db, passkey);
Expand Down
2 changes: 1 addition & 1 deletion packages/fxa-auth-server/lib/routes/account.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4688,7 +4688,7 @@ describe('/account', () => {
const result: any = await runTest(route, request);

expect(mockService.listPasskeysForUser).toHaveBeenCalledWith(
Buffer.from(uid)
Buffer.from(uid, 'hex')
);
expect(result.passkeys).toHaveLength(1);
expect(result.passkeys[0]).toEqual({
Expand Down
4 changes: 3 additions & 1 deletion packages/fxa-auth-server/lib/routes/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2435,7 +2435,9 @@ export class AccountHandler {
this.db.devices(uid),
listAuthorizedClients(uid),
this.config.passkeys?.enabled
? Container.get(PasskeyService).listPasskeysForUser(Buffer.from(uid))
? Container.get(PasskeyService).listPasskeysForUser(
Buffer.from(uid, 'hex')
)
: Promise.resolve([]),
]);

Expand Down
12 changes: 6 additions & 6 deletions packages/fxa-auth-server/lib/routes/passkeys.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe('passkeys routes', () => {
mockPasskeyService: any,
mockFxaMailer: any;

const UID = 'uid-123';
const UID = 'f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6';
const SESSION_TOKEN_ID = 'session-token-456';
const TEST_EMAIL = 'test@example.com';
const CREDENTIAL_ID_B64 =
Expand Down Expand Up @@ -172,7 +172,7 @@ describe('passkeys routes', () => {
).toHaveBeenCalledTimes(1);
expect(
mockPasskeyService.generateRegistrationChallenge
).toHaveBeenCalledWith(Buffer.from(UID), TEST_EMAIL);
).toHaveBeenCalledWith(Buffer.from(UID, 'hex'), TEST_EMAIL);
});

it('enforces rate limiting via customs.checkAuthenticated', async () => {
Expand Down Expand Up @@ -249,7 +249,7 @@ describe('passkeys routes', () => {
expect(
mockPasskeyService.createPasskeyFromRegistrationResponse
).toHaveBeenCalledWith(
Buffer.from(UID),
Buffer.from(UID, 'hex'),
payload.response,
payload.challenge
);
Expand Down Expand Up @@ -440,7 +440,7 @@ describe('passkeys routes', () => {
);

expect(mockPasskeyService.listPasskeysForUser).toHaveBeenCalledWith(
Buffer.from(UID)
Buffer.from(UID, 'hex')
);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
Expand Down Expand Up @@ -520,7 +520,7 @@ describe('passkeys routes', () => {
);

expect(mockPasskeyService.deletePasskey).toHaveBeenCalledWith(
Buffer.from(UID),
Buffer.from(UID, 'hex'),
Buffer.from(CREDENTIAL_ID_B64, 'base64url')
);
});
Expand Down Expand Up @@ -704,7 +704,7 @@ describe('passkeys routes', () => {
);

expect(mockPasskeyService.renamePasskey).toHaveBeenCalledWith(
Buffer.from(UID),
Buffer.from(UID, 'hex'),
Buffer.from(CREDENTIAL_ID_B64, 'base64url'),
'Renamed Key'
);
Expand Down
12 changes: 7 additions & 5 deletions packages/fxa-auth-server/lib/routes/passkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ class PasskeyHandler {
);

const options = await this.service.generateRegistrationChallenge(
Buffer.from(uid),
Buffer.from(uid, 'hex'),
account.email
);

Expand Down Expand Up @@ -128,7 +128,7 @@ class PasskeyHandler {

try {
const passkey = await this.service.createPasskeyFromRegistrationResponse(
Buffer.from(uid),
Buffer.from(uid, 'hex'),
response,
challenge
);
Expand Down Expand Up @@ -213,7 +213,9 @@ class PasskeyHandler {
'passkeysList'
);

const passkeys = await this.service.listPasskeysForUser(Buffer.from(uid));
const passkeys = await this.service.listPasskeysForUser(
Buffer.from(uid, 'hex')
);

// omit publicKey and signCount
return passkeys.map(
Expand Down Expand Up @@ -264,7 +266,7 @@ class PasskeyHandler {

const credentialId = Buffer.from(credentialIdParam, 'base64url');

await this.service.deletePasskey(Buffer.from(uid), credentialId);
await this.service.deletePasskey(Buffer.from(uid, 'hex'), credentialId);

await recordSecurityEvent('account.passkey.removed', {
db: this.db,
Expand Down Expand Up @@ -317,7 +319,7 @@ class PasskeyHandler {
const credentialId = Buffer.from(credentialIdParam, 'base64url');

const passkey = await this.service.renamePasskey(
Buffer.from(uid),
Buffer.from(uid, 'hex'),
credentialId,
name
);
Expand Down
5 changes: 4 additions & 1 deletion packages/fxa-content-server/server/config/local.json-dist
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@
"featureFlags": {
"recoveryCodeSetupOnSyncSignIn": true,
"showLocaleToggle": true,
"paymentsNextSubscriptionManagement": true
"paymentsNextSubscriptionManagement": true,
"passkeysEnabled": true,
"passkeyRegistrationEnabled": true,
"passkeyAuthenticationEnabled": true
},
"darkMode": {
"enabled": true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
import { Localized, useLocalization } from '@fluent/react';
import React, { forwardRef } from 'react';
import UnitRowRecoveryKey from '../UnitRowRecoveryKey';
import { UnitRowPasskey } from '../UnitRowPasskey';
import UnitRowTwoStepAuth from '../UnitRowTwoStepAuth';
import { UnitRow } from '../UnitRow';
import { useAccount } from '../../../models';
import { useAccount, useConfig } from '../../../models';
import {
FtlMsg,
getLocalizedDate,
Expand Down Expand Up @@ -40,6 +41,9 @@ const PwdDate = ({ passwordCreated }: { passwordCreated: number }) => {
export const Security = forwardRef<HTMLDivElement>((_, ref) => {
const { passwordCreated, hasPassword } = useAccount();
const { l10n } = useLocalization();
const config = useConfig();
const passkeyRegistrationEnabled =
config.featureFlags?.passkeyRegistrationEnabled;
const localizedNotSet = l10n.getString('security-not-set', null, 'Not set');

return (
Expand Down Expand Up @@ -85,6 +89,13 @@ export const Security = forwardRef<HTMLDivElement>((_, ref) => {
</Localized>
<hr className="unit-row-hr" />

{passkeyRegistrationEnabled && (
<>
<UnitRowPasskey />
<hr className="unit-row-hr" />
</>
)}
Comment thread
MagentaManifold marked this conversation as resolved.

<UnitRowRecoveryKey />
<hr className="unit-row-hr" />
<UnitRowTwoStepAuth />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { withLocalization, withLocation } from 'fxa-react/lib/storybooks';
import { CodeIcon } from '../../Icons';
import { MOCK_NATIONAL_FORMAT_PHONE_NUMBER } from '../../../pages/mocks';
import { AppContext } from '../../../models';
import { mockAppContext } from '../../../models/mocks';
import { MOCK_ACCOUNT, mockAppContext } from '../../../models/mocks';
import { initLocalAccount, mockAuthClient } from './mock';

export default {
Expand All @@ -25,9 +25,16 @@ export default {
withLocation(),
(Story) => {
initLocalAccount();
const mockAccount = {
...MOCK_ACCOUNT,
deletePasskey: async () => {},
};
return (
<AppContext.Provider
value={mockAppContext({ authClient: mockAuthClient } as any)}
value={mockAppContext({
authClient: mockAuthClient,
account: mockAccount,
} as any)}
>
{/* @container/unitRow on the container div allows sub row to adjust based on the size of the parent container
instead of the viewport. This fixes issues with the subrow and CTAs overflowing their parent container in mobileLandscape
Expand Down Expand Up @@ -135,10 +142,10 @@ export const BackupPhoneAvailableNoDelete: StoryFn = () => (
export const PasskeyWithSync: StoryFn = () => (
<PasskeySubRow
passkey={{
id: '1',
credentialId: '1',
name: 'MacBook Pro',
createdAt: new Date('2026-01-01').getTime(),
lastUsed: new Date('2026-02-01').getTime(),
lastUsedAt: new Date('2026-02-01').getTime(),
prfEnabled: true,
}}
/>
Expand All @@ -147,10 +154,10 @@ export const PasskeyWithSync: StoryFn = () => (
export const PasskeyWithoutSync: StoryFn = () => (
<PasskeySubRow
passkey={{
id: '2',
credentialId: '2',
name: 'iPhone 14 Pro',
createdAt: new Date('2025-12-01').getTime(),
lastUsed: new Date('2026-01-31').getTime(),
lastUsedAt: new Date('2026-01-31').getTime(),
prfEnabled: false,
}}
/>
Expand All @@ -159,9 +166,10 @@ export const PasskeyWithoutSync: StoryFn = () => (
export const PasskeyNeverUsed: StoryFn = () => (
<PasskeySubRow
passkey={{
id: '3',
credentialId: '3',
name: 'Windows PC',
createdAt: new Date('2025-11-01').getTime(),
lastUsedAt: null,
prfEnabled: true,
}}
/>
Comment thread
MagentaManifold marked this conversation as resolved.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ const mockAlertBar = {
info: jest.fn(),
};

let mockAccount = {
deletePasskey: jest.fn(),
};

jest.mock('../../../lib/cache', () => ({
...jest.requireActual('../../../lib/cache'),
JwtTokenCache: {
Expand All @@ -42,6 +46,7 @@ jest.mock('../../../models', () => ({
...jest.requireActual('../../../models'),
useAuthClient: () => mockAuthClient,
useAlertBar: () => mockAlertBar,
useAccount: () => mockAccount,
}));

describe('SubRow', () => {
Expand Down Expand Up @@ -269,29 +274,24 @@ describe('BackupPhoneSubRow', () => {

describe('PasskeySubRow', () => {
const mockPasskey = {
id: 'passkey-1',
credentialId: 'passkey-1',
name: 'MacBook Pro',
createdAt: new Date('2026-01-01').getTime(),
lastUsed: new Date('2026-02-01').getTime(),
lastUsedAt: new Date('2026-02-01').getTime(),
prfEnabled: true,
};

const mockDeletePasskey = jest.fn();

beforeEach(() => {
mockDeletePasskey.mockClear();
mockAccount.deletePasskey.mockClear();
mockAlertBar.success.mockClear();
mockAlertBar.error.mockClear();
});

const renderPasskeySubRow = (
passkey: PasskeyRowData = mockPasskey,
deletePasskey = mockDeletePasskey
) => {
const renderPasskeySubRow = (passkey: PasskeyRowData = mockPasskey) => {
return render(
<LocationProvider>
<AppContext.Provider value={mockAppContext()}>
<PasskeySubRow passkey={passkey} deletePasskey={deletePasskey} />
<PasskeySubRow passkey={passkey} />
</AppContext.Provider>
</LocationProvider>
);
Expand All @@ -307,7 +307,7 @@ describe('PasskeySubRow', () => {
});

it('does not render last used date when not available', () => {
const passkeyWithoutLastUsed = { ...mockPasskey, lastUsed: undefined };
const passkeyWithoutLastUsed = { ...mockPasskey, lastUsedAt: null };
renderPasskeySubRow(passkeyWithoutLastUsed);
expect(screen.queryByText(/Last used:/)).not.toBeInTheDocument();
});
Expand Down Expand Up @@ -346,7 +346,7 @@ describe('PasskeySubRow', () => {
});

it('calls deletePasskey when confirm button is clicked', async () => {
mockDeletePasskey.mockResolvedValue(undefined);
mockAccount.deletePasskey.mockResolvedValue(undefined);
renderPasskeySubRow();

const deleteButtons = screen.getAllByTitle(/Delete passkey/);
Expand All @@ -357,13 +357,11 @@ describe('PasskeySubRow', () => {
const confirmButton = screen.getByTestId('confirm-delete-passkey-button');
await userEvent.click(confirmButton);

await waitFor(() => {
expect(mockDeletePasskey).toHaveBeenCalledWith('passkey-1');
});
expect(mockAccount.deletePasskey).toHaveBeenCalledWith('passkey-1');
});

it('shows success banner when deletion succeeds', async () => {
mockDeletePasskey.mockResolvedValue(undefined);
mockAccount.deletePasskey.mockResolvedValue(undefined);
renderPasskeySubRow();

const deleteButtons = screen.getAllByTitle(/Delete passkey/);
Expand All @@ -386,7 +384,7 @@ describe('PasskeySubRow', () => {
});

it('shows error banner when deletion fails', async () => {
mockDeletePasskey.mockRejectedValue(new Error('Some error'));
mockAccount.deletePasskey.mockRejectedValue(new Error('Some error'));
renderPasskeySubRow();

const deleteButtons = screen.getAllByTitle(/Delete passkey/);
Expand Down
Loading
Loading