Skip to content

Commit e8cd48b

Browse files
Merge pull request #20248 from mozilla/FXA-13076
feat(admin-panel): show passkey info in account search
2 parents 1b419d7 + a43fed1 commit e8cd48b

8 files changed

Lines changed: 505 additions & 2 deletions

File tree

packages/fxa-admin-panel/src/components/PageAccountSearch/Account/index.test.tsx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ let accountResponse: AccountProps = {
9999
subscriptions: [],
100100
linkedAccounts: [],
101101
accountEvents: [],
102+
passkeys: [],
102103
};
103104

104105
it('renders without imploding', () => {
@@ -219,6 +220,82 @@ it('displays the account recovery key status', async () => {
219220
expect(getByTestId('recovery-keys-enabled')).toBeInTheDocument();
220221
});
221222

223+
it('shows "no passkeys" message when passkeys list is empty', () => {
224+
const { getByTestId } = render(<Account {...accountResponse} />);
225+
expect(getByTestId('passkeys-none')).toBeInTheDocument();
226+
});
227+
228+
it('displays passkeys with authenticator name', () => {
229+
const withPasskeys = {
230+
...accountResponse,
231+
passkeys: [
232+
{
233+
name: 'iPhone Face ID',
234+
createdAt: 1589467100316,
235+
lastUsedAt: null,
236+
aaguid: '00000000-0000-0000-0000-000000000000',
237+
authenticatorName: undefined,
238+
backupState: true,
239+
prfEnabled: false,
240+
},
241+
{
242+
name: 'YubiKey 5',
243+
createdAt: 1589467200000,
244+
lastUsedAt: 1700000000000,
245+
aaguid: 'fa2b99dc-9e39-4257-8f92-4a30d23c4118',
246+
authenticatorName: 'YubiKey 5 Series with NFC',
247+
backupState: false,
248+
prfEnabled: true,
249+
},
250+
],
251+
};
252+
const { getAllByTestId } = render(<Account {...withPasskeys} />);
253+
254+
const authenticators = getAllByTestId('passkey-authenticator-name');
255+
expect(authenticators[0]).toHaveTextContent('Unknown');
256+
expect(authenticators[1]).toHaveTextContent('YubiKey 5 Series with NFC');
257+
});
258+
259+
it('displays passkeys with never-used date', () => {
260+
const withPasskey = {
261+
...accountResponse,
262+
passkeys: [
263+
{
264+
name: 'iPhone Face ID',
265+
createdAt: 1589467100316,
266+
lastUsedAt: null,
267+
aaguid: '00000000-0000-0000-0000-000000000000',
268+
authenticatorName: undefined,
269+
backupState: true,
270+
prfEnabled: false,
271+
},
272+
],
273+
};
274+
const { getByTestId } = render(<Account {...withPasskey} />);
275+
276+
expect(getByTestId('passkey-last-used-at')).toHaveTextContent('Never');
277+
});
278+
279+
it('displays passkeys with last-used date', () => {
280+
const withPasskey = {
281+
...accountResponse,
282+
passkeys: [
283+
{
284+
name: 'YubiKey 5',
285+
createdAt: 1589467200000,
286+
lastUsedAt: 1700000000000,
287+
aaguid: 'fa2b99dc-9e39-4257-8f92-4a30d23c4118',
288+
authenticatorName: 'YubiKey 5 Series with NFC',
289+
backupState: false,
290+
prfEnabled: true,
291+
},
292+
],
293+
};
294+
const { getByTestId } = render(<Account {...withPasskey} />);
295+
296+
expect(getByTestId('passkey-last-used-at')).toHaveTextContent('2023-11');
297+
});
298+
222299
it('displays secondary emails', async () => {
223300
accountResponse.emails!.push({
224301

packages/fxa-admin-panel/src/components/PageAccountSearch/Account/index.tsx

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
AccountEvent as AccountEventType,
1111
BackupCodes as BackupCodesType,
1212
RecoveryPhone as RecoveryPhoneType,
13+
Passkey as PasskeyType,
1314
} from 'fxa-admin-server/src/types';
1415
import { AdminPanelFeature } from '@fxa/shared/guards';
1516
import Guard from '../../Guard';
@@ -92,6 +93,7 @@ export const Account = ({
9293
clientSalt,
9394
backupCodes,
9495
recoveryPhone,
96+
passkeys,
9597
}: AccountProps) => {
9698
const createdAtDate = getFormattedDate(createdAt);
9799
const disabledAtDate = getFormattedDate(disabledAt);
@@ -313,11 +315,52 @@ export const Account = ({
313315
</p>
314316
)}
315317

318+
<h3 className="header-lg">Passkeys</h3>
319+
{passkeys && passkeys.length > 0 ? (
320+
<TableXHeaders
321+
rowHeaders={[
322+
'Name',
323+
'Created',
324+
'Last Used',
325+
'Backup State',
326+
'PRF Enabled',
327+
'Authenticator',
328+
]}
329+
>
330+
{passkeys.map((passkey: PasskeyType) => (
331+
<TableRowXHeader key={`${passkey.name}-${passkey.createdAt}`}>
332+
<td data-testid="passkey-name">{passkey.name}</td>
333+
<td data-testid="passkey-created-at">
334+
{getFormattedDate(passkey.createdAt)}
335+
</td>
336+
<td data-testid="passkey-last-used-at">
337+
{passkey.lastUsedAt == null
338+
? 'Never'
339+
: getFormattedDate(passkey.lastUsedAt)}
340+
</td>
341+
<td data-testid="passkey-backup-state">
342+
<ResultBoolean isTruthy={passkey.backupState} />
343+
</td>
344+
<td data-testid="passkey-prf-enabled">
345+
<ResultBoolean isTruthy={passkey.prfEnabled} />
346+
</td>
347+
<td data-testid="passkey-authenticator-name">
348+
{passkey.authenticatorName || 'Unknown'}
349+
</td>
350+
</TableRowXHeader>
351+
))}
352+
</TableXHeaders>
353+
) : (
354+
<p className="result-none" data-testid="passkeys-none">
355+
This account doesn't have any passkeys.
356+
</p>
357+
)}
358+
316359
<h3 className="header-lg">Account Recovery Key</h3>
317360
{recoveryKeys && recoveryKeys.length > 0 ? (
318361
<>
319362
{recoveryKeys.map((recoveryKey: RecoveryKeysType) => (
320-
<TableYHeaders key={createdAt}>
363+
<TableYHeaders key={recoveryKey.createdAt}>
321364
<TableRowYHeader
322365
header="Created At"
323366
children={getFormattedDate(recoveryKey.createdAt)}

packages/fxa-admin-server/src/backend/backend.module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
BouncesFactory,
1818
} from './email.service';
1919
import { DatabaseService } from '../database/database.service';
20+
import { FidoMdsService } from './fido-mds.service';
2021

2122
@Module({
2223
providers: [
@@ -31,6 +32,7 @@ import { DatabaseService } from '../database/database.service';
3132
BouncesFactory,
3233
EmailSenderFactory,
3334
EmailService,
35+
FidoMdsService,
3436
],
3537
exports: [
3638
AuthClientService,
@@ -39,6 +41,7 @@ import { DatabaseService } from '../database/database.service';
3941
DatabaseService,
4042
ProfileClient,
4143
EmailService,
44+
FidoMdsService,
4245
],
4346
})
4447
export class BackendModule {}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { Test, TestingModule } from '@nestjs/testing';
6+
import { ConfigService } from '@nestjs/config';
7+
import { MozLoggerService } from '@fxa/shared/mozlog';
8+
import { FidoMdsService } from './fido-mds.service';
9+
10+
const MDS_URL = 'https://mds.fidoalliance.org/';
11+
const CACHE_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
12+
13+
const mockConfigService = {
14+
get: (key: string) => {
15+
if (key === 'fidoMds') {
16+
return {
17+
url: MDS_URL,
18+
cacheTtlSeconds: CACHE_TTL_SECONDS,
19+
fetchTimeoutSeconds: 10,
20+
};
21+
}
22+
return undefined;
23+
},
24+
};
25+
26+
/** Build a minimal JWT-shaped string with a base64url-encoded payload. */
27+
function makeJwt(payload: object): string {
28+
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
29+
return `header.${payloadB64}.signature`;
30+
}
31+
32+
const YUBIKEY_AAGUID = 'fa2b99dc-9e39-4257-8f92-4a30d23c4118';
33+
const UNKNOWN_AAGUID = '00000000-0000-0000-0000-000000000000';
34+
35+
const MDS_ENTRIES = [
36+
{
37+
aaguid: YUBIKEY_AAGUID,
38+
metadataStatement: { description: 'YubiKey 5 Series with NFC' },
39+
},
40+
{
41+
aaguid: 'cb69481e-8ff7-4039-93ec-0a2729a154a8',
42+
metadataStatement: { description: 'YubiKey 5 FIPS Series' },
43+
},
44+
];
45+
46+
describe('FidoMdsService', () => {
47+
let service: FidoMdsService;
48+
let mockFetch: jest.SpyInstance;
49+
50+
const mockLog = {
51+
info: jest.fn(),
52+
warn: jest.fn(),
53+
};
54+
55+
beforeEach(async () => {
56+
mockFetch = jest.spyOn(global, 'fetch').mockResolvedValue({
57+
ok: true,
58+
text: () => Promise.resolve(makeJwt({ entries: MDS_ENTRIES })),
59+
} as unknown as Response);
60+
61+
const module: TestingModule = await Test.createTestingModule({
62+
providers: [
63+
FidoMdsService,
64+
{ provide: MozLoggerService, useValue: mockLog },
65+
{ provide: ConfigService, useValue: mockConfigService },
66+
],
67+
}).compile();
68+
69+
service = module.get(FidoMdsService);
70+
});
71+
72+
afterEach(() => {
73+
jest.restoreAllMocks();
74+
jest.useRealTimers();
75+
});
76+
77+
it('should be defined', () => {
78+
expect(service).toBeDefined();
79+
});
80+
81+
it('returns the authenticator name for a known AAGUID', async () => {
82+
const name = await service.getAuthenticatorName(YUBIKEY_AAGUID);
83+
expect(name).toBe('YubiKey 5 Series with NFC');
84+
});
85+
86+
it('is case-insensitive for AAGUID lookup', async () => {
87+
const name = await service.getAuthenticatorName(
88+
YUBIKEY_AAGUID.toUpperCase()
89+
);
90+
expect(name).toBe('YubiKey 5 Series with NFC');
91+
});
92+
93+
it('returns undefined for an unknown AAGUID', async () => {
94+
const name = await service.getAuthenticatorName(UNKNOWN_AAGUID);
95+
expect(name).toBeUndefined();
96+
});
97+
98+
it('fetches the MDS only once for multiple concurrent calls', async () => {
99+
await Promise.all([
100+
service.getAuthenticatorName(YUBIKEY_AAGUID),
101+
service.getAuthenticatorName(YUBIKEY_AAGUID),
102+
service.getAuthenticatorName(YUBIKEY_AAGUID),
103+
]);
104+
expect(mockFetch).toHaveBeenCalledTimes(1);
105+
});
106+
107+
it('does not re-fetch before TTL expires', async () => {
108+
await service.getAuthenticatorName(YUBIKEY_AAGUID);
109+
await service.getAuthenticatorName(YUBIKEY_AAGUID);
110+
expect(mockFetch).toHaveBeenCalledTimes(1);
111+
});
112+
113+
it('re-fetches after TTL expires', async () => {
114+
jest.useFakeTimers();
115+
jest.setSystemTime(new Date('2023-01-01T12:00:00.000Z'));
116+
117+
await service.getAuthenticatorName(YUBIKEY_AAGUID);
118+
119+
jest.setSystemTime(new Date('2023-01-10T12:00:00.000Z'));
120+
121+
await service.getAuthenticatorName(YUBIKEY_AAGUID);
122+
expect(mockFetch).toHaveBeenCalledTimes(2);
123+
});
124+
125+
it('returns undefined and allows retry when fetch fails', async () => {
126+
mockFetch.mockRejectedValueOnce(new Error('network error'));
127+
128+
const name = await service.getAuthenticatorName(YUBIKEY_AAGUID);
129+
expect(name).toBeUndefined();
130+
expect(mockLog.warn).toHaveBeenCalledWith(
131+
'FidoMdsService: fetch/parse failed',
132+
expect.anything()
133+
);
134+
135+
// Next call should retry
136+
const retried = await service.getAuthenticatorName(YUBIKEY_AAGUID);
137+
expect(retried).toBe('YubiKey 5 Series with NFC');
138+
expect(mockFetch).toHaveBeenCalledTimes(2);
139+
});
140+
141+
it('returns undefined and allows retry when server returns non-ok status', async () => {
142+
mockFetch.mockResolvedValueOnce({ ok: false, status: 503 });
143+
144+
const name = await service.getAuthenticatorName(YUBIKEY_AAGUID);
145+
expect(name).toBeUndefined();
146+
expect(mockLog.warn).toHaveBeenCalled();
147+
});
148+
149+
it('logs a cache-refreshed message with entry count on success', async () => {
150+
await service.getAuthenticatorName(YUBIKEY_AAGUID);
151+
expect(mockLog.info).toHaveBeenCalledWith(
152+
'FidoMdsService: cache refreshed',
153+
{ entries: MDS_ENTRIES.length }
154+
);
155+
});
156+
157+
it('skips entries that are missing aaguid or description', async () => {
158+
mockFetch.mockResolvedValueOnce({
159+
ok: true,
160+
text: () =>
161+
Promise.resolve(
162+
makeJwt({
163+
entries: [
164+
{ aaguid: 'aaguid-no-description' },
165+
{ metadataStatement: { description: 'no aaguid' } },
166+
...MDS_ENTRIES,
167+
],
168+
})
169+
),
170+
});
171+
172+
// Only the valid MDS_ENTRIES should be in the cache
173+
const name = await service.getAuthenticatorName(YUBIKEY_AAGUID);
174+
expect(name).toBe('YubiKey 5 Series with NFC');
175+
176+
const missing = await service.getAuthenticatorName('aaguid-no-description');
177+
expect(missing).toBeUndefined();
178+
});
179+
});

0 commit comments

Comments
 (0)