AtAuth
at main 373 lines 12 kB view raw
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});