|
8 | 8 | verifyWebauthnRegistrationResponse, |
9 | 9 | generateWebauthnAuthenticationOptions, |
10 | 10 | verifyWebauthnAuthenticationResponse, |
| 11 | + extractPrfEnabled, |
11 | 12 | } from './webauthn-adapter'; |
12 | 13 | import { PasskeyConfig } from './passkey.config'; |
13 | 14 | import { VirtualAuthenticator } from './virtual-authenticator'; |
@@ -159,6 +160,50 @@ describe('generateWebauthnRegistrationOptions', () => { |
159 | 160 | { id: credId, type: 'public-key' }, |
160 | 161 | ]); |
161 | 162 | }); |
| 163 | + |
| 164 | + it('requests the PRF extension so authenticators report PRF support', async () => { |
| 165 | + const options = await generateWebauthnRegistrationOptions(testConfig(), { |
| 166 | + uid: Buffer.alloc(16, 0xbb).toString('hex'), |
| 167 | + email: 'bob@example.com', |
| 168 | + challenge: randomBytes(32).toString('base64url'), |
| 169 | + }); |
| 170 | + |
| 171 | + // credProps is added by SimpleWebAuthn itself; we only own the PRF probe. |
| 172 | + expect(options.extensions).toEqual(expect.objectContaining({ prf: {} })); |
| 173 | + }); |
| 174 | +}); |
| 175 | + |
| 176 | +describe('extractPrfEnabled', () => { |
| 177 | + it.each([ |
| 178 | + { |
| 179 | + label: 'prf.enabled is true', |
| 180 | + input: { prf: { enabled: true } }, |
| 181 | + expected: true, |
| 182 | + }, |
| 183 | + { |
| 184 | + label: 'prf.enabled is false', |
| 185 | + input: { prf: { enabled: false } }, |
| 186 | + expected: false, |
| 187 | + }, |
| 188 | + { |
| 189 | + label: 'prf is present without enabled', |
| 190 | + input: { prf: {} }, |
| 191 | + expected: false, |
| 192 | + }, |
| 193 | + { |
| 194 | + label: 'prf.enabled is a non-boolean truthy value', |
| 195 | + input: { prf: { enabled: 'false' } }, |
| 196 | + expected: false, |
| 197 | + }, |
| 198 | + { label: 'prf is absent', input: {}, expected: false }, |
| 199 | + { |
| 200 | + label: 'client extension results are undefined', |
| 201 | + input: undefined, |
| 202 | + expected: false, |
| 203 | + }, |
| 204 | + ])('returns $expected when $label', ({ input, expected }) => { |
| 205 | + expect(extractPrfEnabled(input)).toBe(expected); |
| 206 | + }); |
162 | 207 | }); |
163 | 208 |
|
164 | 209 | describe('verifyWebauthnRegistrationResponse', () => { |
@@ -186,18 +231,48 @@ describe('verifyWebauthnRegistrationResponse', () => { |
186 | 231 | }); |
187 | 232 |
|
188 | 233 | expect(result.verified).toBe(true); |
189 | | - if (!result.verified) throw new Error('narrowing'); |
190 | 234 |
|
191 | | - expect(typeof result.data.credentialId).toBe('string'); |
192 | | - expect(result.data.credentialId).toBe(cred.id.toString('base64url')); |
193 | | - expect(result.data.publicKey).toBeInstanceOf(Buffer); |
194 | | - expect(result.data.signCount).toBe(0); |
195 | | - expect(result.data.transports).toEqual(['internal']); |
196 | | - expect(typeof result.data.aaguid).toBe('string'); |
197 | | - expect(result.data.aaguid).toBe('00000000-0000-0000-0000-000000000000'); |
198 | | - expect(result.data.backupEligible).toBe(false); |
199 | | - expect(result.data.backupState).toBe(false); |
200 | | - expect(result.data.prfEnabled).toBe(false); |
| 235 | + expect(typeof result.data?.credentialId).toBe('string'); |
| 236 | + expect(result.data?.credentialId).toBe(cred.id.toString('base64url')); |
| 237 | + expect(result.data?.publicKey).toBeInstanceOf(Buffer); |
| 238 | + expect(result.data?.signCount).toBe(0); |
| 239 | + expect(result.data?.transports).toEqual(['internal']); |
| 240 | + expect(typeof result.data?.aaguid).toBe('string'); |
| 241 | + expect(result.data?.aaguid).toBe('00000000-0000-0000-0000-000000000000'); |
| 242 | + expect(result.data?.backupEligible).toBe(false); |
| 243 | + expect(result.data?.backupState).toBe(false); |
| 244 | + // No prf in clientExtensionResults → not PRF-enabled. |
| 245 | + expect(result.data?.prfEnabled).toBe(false); |
| 246 | + }); |
| 247 | + |
| 248 | + it('records prfEnabled when the browser reports PRF support', async () => { |
| 249 | + const cred = VirtualAuthenticator.createCredential(); |
| 250 | + const challenge = randomBytes(32).toString('base64url'); |
| 251 | + |
| 252 | + const options = await generateWebauthnRegistrationOptions(config, { |
| 253 | + uid: Buffer.alloc(16, 0xaa).toString('hex'), |
| 254 | + email: 'test@example.com', |
| 255 | + challenge, |
| 256 | + }); |
| 257 | + |
| 258 | + const attestation = VirtualAuthenticator.createAttestationResponse(cred, { |
| 259 | + challenge: options.challenge, |
| 260 | + origin: TEST_ORIGIN, |
| 261 | + rpId: TEST_RP_ID, |
| 262 | + }); |
| 263 | + // prf.enabled is a WebAuthn Level 3 client-extension output the browser |
| 264 | + // sets; the virtual authenticator leaves clientExtensionResults empty, so |
| 265 | + // attach it here. (Field absent from SimpleWebAuthn's output type.) |
| 266 | + (attestation.clientExtensionResults as { prf?: { enabled: boolean } }).prf = |
| 267 | + { enabled: true }; |
| 268 | + |
| 269 | + const result = await verifyWebauthnRegistrationResponse(config, { |
| 270 | + response: attestation, |
| 271 | + challenge, |
| 272 | + }); |
| 273 | + |
| 274 | + expect(result.verified).toBe(true); |
| 275 | + expect(result.data?.prfEnabled).toBe(true); |
201 | 276 | }); |
202 | 277 |
|
203 | 278 | it('rejects a mismatched challenge', async () => { |
@@ -380,9 +455,8 @@ describe('verifyWebauthnAuthenticationResponse', () => { |
380 | 455 | }); |
381 | 456 |
|
382 | 457 | expect(result.verified).toBe(true); |
383 | | - if (!result.verified) throw new Error('narrowing'); |
384 | | - expect(result.data.newSignCount).toBe(1); |
385 | | - expect(result.data.backupState).toBe(false); |
| 458 | + expect(result.data?.newSignCount).toBe(1); |
| 459 | + expect(result.data?.backupState).toBe(false); |
386 | 460 | }); |
387 | 461 |
|
388 | 462 | it('tracks incrementing sign counts across assertions', async () => { |
|
0 commit comments