AtAuth
1import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2import { PasskeyService } from './passkey.js';
3import { DatabaseService } from './database.js';
4
5// Mock @simplewebauthn/server
6vi.mock('@simplewebauthn/server', () => ({
7 generateRegistrationOptions: vi.fn().mockResolvedValue({
8 challenge: 'mock-challenge-registration',
9 rp: { name: 'Test', id: 'localhost' },
10 user: { id: 'dGVzdA', name: 'test', displayName: 'test' },
11 pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
12 authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
13 }),
14 verifyRegistrationResponse: vi.fn().mockResolvedValue({
15 verified: true,
16 registrationInfo: {
17 credential: {
18 id: 'cred-id-123',
19 publicKey: Buffer.from('public-key-bytes'),
20 counter: 0,
21 },
22 credentialDeviceType: 'singleDevice',
23 credentialBackedUp: false,
24 },
25 }),
26 generateAuthenticationOptions: vi.fn().mockResolvedValue({
27 challenge: 'mock-challenge-authentication',
28 rpId: 'localhost',
29 allowCredentials: [],
30 userVerification: 'preferred',
31 }),
32 verifyAuthenticationResponse: vi.fn().mockResolvedValue({
33 verified: true,
34 authenticationInfo: {
35 newCounter: 1,
36 credentialID: 'cred-id-123',
37 },
38 }),
39}));
40
41const PASSKEY_CONFIG = {
42 rpName: 'Test RP',
43 rpID: 'localhost',
44 origin: 'http://localhost:3000',
45};
46
47describe('PasskeyService', () => {
48 let db: DatabaseService;
49 let service: PasskeyService;
50
51 beforeEach(() => {
52 db = new DatabaseService(':memory:');
53 service = new PasskeyService(db, PASSKEY_CONFIG);
54 });
55
56 afterEach(() => {
57 db.close();
58 vi.clearAllMocks();
59 });
60
61 describe('generateRegistrationOptions', () => {
62 it('should return registration options', async () => {
63 const options = await service.generateRegistrationOptions('did:plc:test', 'test.bsky.social');
64 expect(options).toBeDefined();
65 expect(options.challenge).toBe('mock-challenge-registration');
66 });
67
68 it('should exclude existing credentials', async () => {
69 // Save an existing credential
70 db.savePasskeyCredential({
71 id: 'existing-cred',
72 did: 'did:plc:test',
73 handle: 'test.bsky.social',
74 public_key: Buffer.from('key').toString('base64'),
75 counter: 0,
76 device_type: 'platform',
77 backed_up: false,
78 transports: null,
79 name: null,
80 });
81
82 await service.generateRegistrationOptions('did:plc:test', 'test.bsky.social');
83
84 const { generateRegistrationOptions } = await import('@simplewebauthn/server');
85 expect(generateRegistrationOptions).toHaveBeenCalledWith(
86 expect.objectContaining({
87 excludeCredentials: expect.arrayContaining([
88 expect.objectContaining({ id: 'existing-cred' }),
89 ]),
90 })
91 );
92 });
93 });
94
95 describe('verifyRegistration', () => {
96 it('should verify and store a credential', async () => {
97 // First generate options to store challenge
98 await service.generateRegistrationOptions('did:plc:test', 'test.bsky.social');
99
100 const result = await service.verifyRegistration(
101 'did:plc:test',
102 'test.bsky.social',
103 { id: 'cred-1', rawId: 'raw', response: { clientDataJSON: 'x', attestationObject: 'y', transports: ['internal'] }, type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform' },
104 'My Passkey',
105 );
106
107 expect(result.success).toBe(true);
108 expect(result.credentialId).toBe('cred-id-123');
109
110 // Credential should be stored in DB
111 const stored = db.getPasskeyCredential('cred-id-123');
112 expect(stored).not.toBeNull();
113 expect(stored!.did).toBe('did:plc:test');
114 });
115
116 it('should return error when no challenge exists', async () => {
117 const result = await service.verifyRegistration(
118 'did:plc:unknown',
119 'unknown',
120 { id: 'x', rawId: 'x', response: { clientDataJSON: 'x', attestationObject: 'y' }, type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform' } as any,
121 );
122
123 expect(result.success).toBe(false);
124 expect(result.error).toContain('No registration challenge');
125 });
126
127 it('should return error when challenge is expired', async () => {
128 vi.useFakeTimers();
129 const now = Date.now();
130 vi.setSystemTime(now);
131
132 await service.generateRegistrationOptions('did:plc:test', 'test.bsky.social');
133
134 // Advance past 5 minute expiry
135 vi.setSystemTime(now + 6 * 60 * 1000);
136
137 const result = await service.verifyRegistration(
138 'did:plc:test',
139 'test.bsky.social',
140 { id: 'x', rawId: 'x', response: { clientDataJSON: 'x', attestationObject: 'y' }, type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform' } as any,
141 );
142
143 expect(result.success).toBe(false);
144 expect(result.error).toContain('expired');
145
146 vi.useRealTimers();
147 });
148 });
149
150 describe('generateAuthenticationOptions', () => {
151 it('should generate options without DID (discoverable)', async () => {
152 const options = await service.generateAuthenticationOptions();
153 expect(options).toBeDefined();
154 expect(options.challenge).toBe('mock-challenge-authentication');
155 });
156
157 it('should include credentials when DID provided', async () => {
158 db.savePasskeyCredential({
159 id: 'user-cred',
160 did: 'did:plc:test',
161 handle: 'test.bsky.social',
162 public_key: Buffer.from('key').toString('base64'),
163 counter: 0,
164 device_type: 'platform',
165 backed_up: false,
166 transports: ['internal'],
167 name: null,
168 });
169
170 await service.generateAuthenticationOptions('did:plc:test');
171
172 const { generateAuthenticationOptions } = await import('@simplewebauthn/server');
173 expect(generateAuthenticationOptions).toHaveBeenCalledWith(
174 expect.objectContaining({
175 allowCredentials: expect.arrayContaining([
176 expect.objectContaining({ id: 'user-cred' }),
177 ]),
178 })
179 );
180 });
181 });
182
183 describe('verifyAuthentication', () => {
184 it('should verify and return user info', async () => {
185 // Store credential
186 db.savePasskeyCredential({
187 id: 'cred-id-123',
188 did: 'did:plc:test',
189 handle: 'test.bsky.social',
190 public_key: Buffer.from('key').toString('base64'),
191 counter: 0,
192 device_type: 'platform',
193 backed_up: false,
194 transports: null,
195 name: null,
196 });
197
198 // Generate options to store challenge
199 await service.generateAuthenticationOptions();
200
201 const result = await service.verifyAuthentication(
202 { id: 'cred-id-123', rawId: 'raw', response: { clientDataJSON: 'x', authenticatorData: 'y', signature: 'z' }, type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform' },
203 'mock-challenge-authentication',
204 );
205
206 expect(result.success).toBe(true);
207 expect(result.did).toBe('did:plc:test');
208 expect(result.handle).toBe('test.bsky.social');
209 });
210
211 it('should return error for unknown credential', async () => {
212 await service.generateAuthenticationOptions();
213
214 const result = await service.verifyAuthentication(
215 { id: 'unknown-cred', rawId: 'raw', response: { clientDataJSON: 'x', authenticatorData: 'y', signature: 'z' }, type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform' },
216 'mock-challenge-authentication',
217 );
218
219 expect(result.success).toBe(false);
220 expect(result.error).toContain('Unknown credential');
221 });
222
223 it('should return error when no challenge exists', async () => {
224 const result = await service.verifyAuthentication(
225 { id: 'x', rawId: 'raw', response: { clientDataJSON: 'x', authenticatorData: 'y', signature: 'z' }, type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform' },
226 'nonexistent-challenge',
227 );
228
229 expect(result.success).toBe(false);
230 expect(result.error).toContain('No authentication challenge');
231 });
232 });
233
234 describe('listPasskeys', () => {
235 it('should list passkeys for a user', () => {
236 db.savePasskeyCredential({
237 id: 'cred-1',
238 did: 'did:plc:test',
239 handle: 'test.bsky.social',
240 public_key: Buffer.from('key1').toString('base64'),
241 counter: 0,
242 device_type: 'platform',
243 backed_up: true,
244 transports: ['internal'],
245 name: 'My Macbook',
246 });
247
248 db.savePasskeyCredential({
249 id: 'cred-2',
250 did: 'did:plc:test',
251 handle: 'test.bsky.social',
252 public_key: Buffer.from('key2').toString('base64'),
253 counter: 0,
254 device_type: 'cross-platform',
255 backed_up: false,
256 transports: ['usb'],
257 name: 'YubiKey',
258 });
259
260 const passkeys = service.listPasskeys('did:plc:test');
261 expect(passkeys).toHaveLength(2);
262 expect(passkeys[0].name).toBe('My Macbook');
263 expect(passkeys[1].name).toBe('YubiKey');
264 });
265
266 it('should return empty array for user with no passkeys', () => {
267 const passkeys = service.listPasskeys('did:plc:nobody');
268 expect(passkeys).toEqual([]);
269 });
270 });
271
272 describe('renamePasskey', () => {
273 it('should rename an existing passkey', () => {
274 db.savePasskeyCredential({
275 id: 'cred-1',
276 did: 'did:plc:test',
277 handle: 'test.bsky.social',
278 public_key: Buffer.from('key').toString('base64'),
279 counter: 0,
280 device_type: 'platform',
281 backed_up: false,
282 transports: null,
283 name: 'Old Name',
284 });
285
286 const result = service.renamePasskey('did:plc:test', 'cred-1', 'New Name');
287 expect(result).toBe(true);
288 });
289
290 it('should return false for wrong DID', () => {
291 db.savePasskeyCredential({
292 id: 'cred-1',
293 did: 'did:plc:other',
294 handle: 'other.bsky.social',
295 public_key: Buffer.from('key').toString('base64'),
296 counter: 0,
297 device_type: 'platform',
298 backed_up: false,
299 transports: null,
300 name: null,
301 });
302
303 const result = service.renamePasskey('did:plc:test', 'cred-1', 'New Name');
304 expect(result).toBe(false);
305 });
306
307 it('should return false for nonexistent credential', () => {
308 const result = service.renamePasskey('did:plc:test', 'nonexistent', 'Name');
309 expect(result).toBe(false);
310 });
311 });
312
313 describe('deletePasskey', () => {
314 it('should delete an existing passkey', () => {
315 db.savePasskeyCredential({
316 id: 'cred-1',
317 did: 'did:plc:test',
318 handle: 'test.bsky.social',
319 public_key: Buffer.from('key').toString('base64'),
320 counter: 0,
321 device_type: 'platform',
322 backed_up: false,
323 transports: null,
324 name: null,
325 });
326
327 const result = service.deletePasskey('did:plc:test', 'cred-1');
328 expect(result).toBe(true);
329 expect(db.getPasskeyCredential('cred-1')).toBeNull();
330 });
331
332 it('should return false for wrong DID', () => {
333 db.savePasskeyCredential({
334 id: 'cred-1',
335 did: 'did:plc:other',
336 handle: 'other',
337 public_key: Buffer.from('key').toString('base64'),
338 counter: 0,
339 device_type: 'platform',
340 backed_up: false,
341 transports: null,
342 name: null,
343 });
344
345 const result = service.deletePasskey('did:plc:test', 'cred-1');
346 expect(result).toBe(false);
347 });
348 });
349
350 describe('hasPasskeys / getPasskeyCount', () => {
351 it('should return false and 0 for user with no passkeys', () => {
352 expect(service.hasPasskeys('did:plc:nobody')).toBe(false);
353 expect(service.getPasskeyCount('did:plc:nobody')).toBe(0);
354 });
355
356 it('should return true and correct count', () => {
357 db.savePasskeyCredential({
358 id: 'cred-1',
359 did: 'did:plc:test',
360 handle: 'test',
361 public_key: Buffer.from('key').toString('base64'),
362 counter: 0,
363 device_type: 'platform',
364 backed_up: false,
365 transports: null,
366 name: null,
367 });
368
369 expect(service.hasPasskeys('did:plc:test')).toBe(true);
370 expect(service.getPasskeyCount('did:plc:test')).toBe(1);
371 });
372 });
373});