Skip to content

Commit 8ddaccb

Browse files
committed
fix(passkey): expose granular passkey methods on useAuth0().passkey
1 parent e0b1eca commit 8ddaccb

4 files changed

Lines changed: 149 additions & 3 deletions

File tree

‎__mocks__/@auth0/auth0-spa-js.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ const mfaGetEnrollmentFactors = jest.fn(() => Promise.resolve([]));
3131
const passkeySignup = jest.fn(() => Promise.resolve({ access_token: 'passkey-token', id_token: 'passkey-id-token' }));
3232
const passkeyLogin = jest.fn(() => Promise.resolve({ access_token: 'passkey-token', id_token: 'passkey-id-token' }));
3333
const passkeyContinueSignup = jest.fn(() => Promise.resolve({ access_token: 'passkey-token', id_token: 'passkey-id-token' }));
34+
const passkeyGetSignupChallenge = jest.fn(() => Promise.resolve({ authSession: 'signup-auth-session', publicKey: {} }));
35+
const passkeyGetLoginChallenge = jest.fn(() => Promise.resolve({ authSession: 'login-auth-session', publicKey: {} }));
36+
const passkeyGetTokenWithPasskey = jest.fn(() => Promise.resolve({ access_token: 'passkey-token', id_token: 'passkey-id-token' }));
3437

3538
const myAccountGetFactors = jest.fn(() => Promise.resolve([]));
3639
const myAccountGetAuthenticationMethods = jest.fn(() => Promise.resolve([]));
@@ -82,6 +85,9 @@ export const Auth0Client = jest.fn(() => {
8285
signup: passkeySignup,
8386
login: passkeyLogin,
8487
continueSignup: passkeyContinueSignup,
88+
getSignupChallenge: passkeyGetSignupChallenge,
89+
getLoginChallenge: passkeyGetLoginChallenge,
90+
getTokenWithPasskey: passkeyGetTokenWithPasskey,
8591
},
8692
myAccount: {
8793
getFactors: myAccountGetFactors,

‎__tests__/passkey.test.tsx‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,4 +627,99 @@ describe('Passkey API', () => {
627627
).rejects.toThrow('User cancelled');
628628
});
629629
});
630+
631+
describe('passkey granular flow', () => {
632+
const credential = {} as PublicKeyCredential;
633+
634+
it('should forward getSignupChallenge to the underlying client', async () => {
635+
const wrapper = createWrapper();
636+
const { result } = renderHook(() => useAuth0(), { wrapper });
637+
638+
await waitFor(() => expect(result.current.isLoading).toBe(false));
639+
640+
const challenge = await result.current.passkey.getSignupChallenge({
641+
email: 'user@example.com',
642+
});
643+
644+
expect(clientMock.passkey.getSignupChallenge).toHaveBeenCalledWith({
645+
email: 'user@example.com',
646+
});
647+
expect(challenge.authSession).toBe('signup-auth-session');
648+
});
649+
650+
it('should forward getLoginChallenge to the underlying client', async () => {
651+
const wrapper = createWrapper();
652+
const { result } = renderHook(() => useAuth0(), { wrapper });
653+
654+
await waitFor(() => expect(result.current.isLoading).toBe(false));
655+
656+
const challenge = await result.current.passkey.getLoginChallenge({
657+
realm: 'Username-Password-Authentication',
658+
});
659+
660+
expect(clientMock.passkey.getLoginChallenge).toHaveBeenCalledWith({
661+
realm: 'Username-Password-Authentication',
662+
});
663+
expect(challenge.authSession).toBe('login-auth-session');
664+
});
665+
666+
it('should update isAuthenticated and user after getTokenWithPasskey', async () => {
667+
const wrapper = createWrapper();
668+
const { result } = renderHook(() => useAuth0(), { wrapper });
669+
670+
await waitFor(() => expect(result.current.isLoading).toBe(false));
671+
expect(result.current.isAuthenticated).toBe(false);
672+
673+
clientMock.getUser.mockResolvedValue(user);
674+
let tokenResponse: TokenEndpointResponse | undefined;
675+
await act(async () => {
676+
tokenResponse = await result.current.passkey.getTokenWithPasskey({
677+
authSession: 'login-auth-session',
678+
credential,
679+
});
680+
});
681+
682+
expect(clientMock.passkey.getTokenWithPasskey).toHaveBeenCalledWith({
683+
authSession: 'login-auth-session',
684+
credential,
685+
});
686+
expect(tokenResponse?.access_token).toBe('passkey-token');
687+
expect(result.current.isAuthenticated).toBe(true);
688+
expect(result.current.user).toEqual(user);
689+
});
690+
691+
it('should rethrow errors from getTokenWithPasskey', async () => {
692+
clientMock.passkey.getTokenWithPasskey.mockRejectedValueOnce(
693+
new PasskeyError('passkey_invalid_credential', 'Invalid credential')
694+
);
695+
696+
const wrapper = createWrapper();
697+
const { result } = renderHook(() => useAuth0(), { wrapper });
698+
699+
await waitFor(() => expect(result.current.isLoading).toBe(false));
700+
701+
await expect(
702+
act(async () => {
703+
await result.current.passkey.getTokenWithPasskey({
704+
authSession: 'login-auth-session',
705+
credential,
706+
});
707+
})
708+
).rejects.toThrow('Invalid credential');
709+
});
710+
711+
it('should throw when the granular methods are used outside Auth0Provider', () => {
712+
const { result } = renderHook(() => useAuth0());
713+
714+
expect(result.current.passkey.getSignupChallenge).toThrow(
715+
'You forgot to wrap your component in <Auth0Provider>.'
716+
);
717+
expect(result.current.passkey.getLoginChallenge).toThrow(
718+
'You forgot to wrap your component in <Auth0Provider>.'
719+
);
720+
expect(result.current.passkey.getTokenWithPasskey).toThrow(
721+
'You forgot to wrap your component in <Auth0Provider>.'
722+
);
723+
});
724+
});
630725
});

‎src/auth0-context.tsx‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -481,9 +481,13 @@ export interface Auth0ContextInterface<TUser extends User = User>
481481
* - `login(options?)` — authenticate an existing user via passkey assertion
482482
* - `continueSignup(verification)` — finish a signup that threw
483483
* `PasskeyVerificationRequiredError`, passing the OTP codes the user entered
484+
* - `getSignupChallenge(options)` / `getLoginChallenge(options?)` — request
485+
* a challenge so you can run the WebAuthn ceremony yourself
486+
* - `getTokenWithPasskey(options)` — exchange the credential from that
487+
* ceremony for tokens
484488
*
485-
* These methods exchange the WebAuthn credential for Auth0 tokens and update
486-
* `isAuthenticated` / `user` in the same way as `loginWithPopup`.
489+
* The methods that return tokens update `isAuthenticated` / `user` in the
490+
* same way as `loginWithPopup`.
487491
*
488492
* @category Sub-clients
489493
*/
@@ -597,6 +601,9 @@ export const initialContext = {
597601
signup: stub,
598602
login: stub,
599603
continueSignup: stub,
604+
getSignupChallenge: stub,
605+
getLoginChallenge: stub,
606+
getTokenWithPasskey: stub,
600607
} as unknown as PasskeyApiClient,
601608
myAccount: {
602609
getFactors: stub,

‎src/auth0-provider.tsx‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -560,14 +560,52 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
560560
[client]
561561
);
562562

563+
const passkeyGetSignupChallenge = useCallback<
564+
PasskeyApiClient['getSignupChallenge']
565+
>((options) => client.passkey.getSignupChallenge(options), [client]);
566+
567+
const passkeyGetLoginChallenge = useCallback<
568+
PasskeyApiClient['getLoginChallenge']
569+
>((options) => client.passkey.getLoginChallenge(options), [client]);
570+
571+
const passkeyGetTokenWithPasskey = useCallback(
572+
async (
573+
options: Parameters<PasskeyApiClient['getTokenWithPasskey']>[0]
574+
): Promise<TokenEndpointResponse> => {
575+
let tokenResponse;
576+
try {
577+
tokenResponse = await client.passkey.getTokenWithPasskey(options);
578+
} catch (error) {
579+
throw tokenError(error);
580+
} finally {
581+
dispatch({
582+
type: 'GET_ACCESS_TOKEN_COMPLETE',
583+
user: await client.getUser(),
584+
});
585+
}
586+
return tokenResponse;
587+
},
588+
[client]
589+
);
590+
563591
const passkey = useMemo(
564592
() =>
565593
({
566594
signup: passkeySignup,
567595
login: passkeyLogin,
568596
continueSignup: passkeyContinueSignup,
597+
getSignupChallenge: passkeyGetSignupChallenge,
598+
getLoginChallenge: passkeyGetLoginChallenge,
599+
getTokenWithPasskey: passkeyGetTokenWithPasskey,
569600
}) as unknown as PasskeyApiClient,
570-
[passkeySignup, passkeyLogin, passkeyContinueSignup]
601+
[
602+
passkeySignup,
603+
passkeyLogin,
604+
passkeyContinueSignup,
605+
passkeyGetSignupChallenge,
606+
passkeyGetLoginChallenge,
607+
passkeyGetTokenWithPasskey,
608+
]
571609
);
572610

573611
const contextValue = useMemo<Auth0ContextInterface<TUser>>(() => {

0 commit comments

Comments
 (0)