Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion __mocks__/@auth0/auth0-spa-js.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ const mfaChallenge = jest.fn(() => Promise.resolve({ challengeType: 'otp', oobCo
const mfaVerify = jest.fn(() => Promise.resolve({ access_token: 'test-token', id_token: 'test-id-token' }));
const mfaGetEnrollmentFactors = jest.fn(() => Promise.resolve([]));

const passkeySignup = jest.fn(() => Promise.resolve({ access_token: 'passkey-token', id_token: 'passkey-id-token' }));
const passkeyLogin = jest.fn(() => Promise.resolve({ access_token: 'passkey-token', id_token: 'passkey-id-token' }));

export const Auth0Client = jest.fn(() => {
return {
buildAuthorizeUrl,
Expand Down Expand Up @@ -57,6 +60,10 @@ export const Auth0Client = jest.fn(() => {
verify: mfaVerify,
getEnrollmentFactors: mfaGetEnrollmentFactors,
},
passkey: {
signup: passkeySignup,
login: passkeyLogin,
},
};
});

Expand All @@ -67,4 +74,9 @@ export const MfaListAuthenticatorsError = actual.MfaListAuthenticatorsError;
export const MfaEnrollmentError = actual.MfaEnrollmentError;
export const MfaChallengeError = actual.MfaChallengeError;
export const MfaVerifyError = actual.MfaVerifyError;
export const MfaEnrollmentFactorsError = actual.MfaEnrollmentFactorsError;
export const MfaEnrollmentFactorsError = actual.MfaEnrollmentFactorsError;

export const PasskeyError = actual.PasskeyError;
export const PasskeyRegisterError = actual.PasskeyRegisterError;
export const PasskeyChallengeError = actual.PasskeyChallengeError;
export const PasskeyGetTokenError = actual.PasskeyGetTokenError;
143 changes: 143 additions & 0 deletions __tests__/passkey.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { Auth0Client, TokenEndpointResponse } from '@auth0/auth0-spa-js';
import { act, renderHook, waitFor } from '@testing-library/react';
import useAuth0 from '../src/use-auth0';
import { createWrapper } from './helpers';

const clientMock = jest.mocked(new Auth0Client({ clientId: '', domain: '' }));

describe('Passkey API', () => {
describe('Basic Availability', () => {
it('should provide passkey client through useAuth0', async () => {
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth0(), { wrapper });

await waitFor(() => {
expect(result.current.passkey).toBeDefined();
});
});

it('should provide signup and login methods', async () => {
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth0(), { wrapper });

await waitFor(() => {
expect(result.current.passkey.signup).toBeDefined();
expect(result.current.passkey.login).toBeDefined();
});
});
});

describe('passkey.signup', () => {
it('should return token response', async () => {
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth0(), { wrapper });

await waitFor(() => expect(result.current.isLoading).toBe(false));

let tokenResponse: TokenEndpointResponse | undefined;
await act(async () => {
tokenResponse = await result.current.passkey.signup({ email: 'user@example.com' });
});

expect(tokenResponse).toBeDefined();
expect(tokenResponse?.access_token).toBe('passkey-token');
expect(tokenResponse?.id_token).toBe('passkey-id-token');
});

it('should dispatch state update after signup', async () => {
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth0(), { wrapper });

await waitFor(() => expect(result.current.isLoading).toBe(false));

await act(async () => {
await result.current.passkey.signup({ email: 'user@example.com' });
});

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
});

it('should rethrow errors from passkey.signup', async () => {
clientMock.passkey.signup.mockRejectedValueOnce(new Error('WebAuthn not supported'));

const wrapper = createWrapper();
const { result } = renderHook(() => useAuth0(), { wrapper });

await waitFor(() => expect(result.current.isLoading).toBe(false));

await expect(
act(async () => {
await result.current.passkey.signup({ email: 'user@example.com' });
})
).rejects.toThrow('WebAuthn not supported');
});
});

describe('passkey.login', () => {
it('should return token response', async () => {
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth0(), { wrapper });

await waitFor(() => expect(result.current.isLoading).toBe(false));

let tokenResponse: TokenEndpointResponse | undefined;
await act(async () => {
tokenResponse = await result.current.passkey.login();
});

expect(tokenResponse).toBeDefined();
expect(tokenResponse?.access_token).toBe('passkey-token');
});

it('should forward options to the underlying client', async () => {
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth0(), { wrapper });

await waitFor(() => expect(result.current.isLoading).toBe(false));

await act(async () => {
await result.current.passkey.login({
realm: 'Username-Password-Authentication',
scope: 'openid profile email',
});
});

expect(clientMock.passkey.login).toHaveBeenCalledWith({
realm: 'Username-Password-Authentication',
scope: 'openid profile email',
});
});

it('should dispatch state update after login', async () => {
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth0(), { wrapper });

await waitFor(() => expect(result.current.isLoading).toBe(false));

await act(async () => {
await result.current.passkey.login();
});

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
});

it('should rethrow errors from passkey.login', async () => {
clientMock.passkey.login.mockRejectedValueOnce(new Error('User cancelled'));

const wrapper = createWrapper();
const { result } = renderHook(() => useAuth0(), { wrapper });

await waitFor(() => expect(result.current.isLoading).toBe(false));

await expect(
act(async () => {
await result.current.passkey.login();
})
).rejects.toThrow('User cancelled');
});
});
});
23 changes: 22 additions & 1 deletion src/auth0-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import {
ConnectAccountRedirectResult,
CustomTokenExchangeOptions,
TokenEndpointResponse,
type MfaApiClient
type MfaApiClient,
type PasskeyApiClient
} from '@auth0/auth0-spa-js';
import { createContext } from 'react';
import { AuthState, initialAuthState } from './auth-state';
Expand Down Expand Up @@ -390,6 +391,22 @@ export interface Auth0ContextInterface<TUser extends User = User>
* ```
*/
mfa: MfaApiClient;

/**
* ```js
* const { passkey } = useAuth0();
* const tokens = await passkey.signup({ email: 'user@example.com' });
* ```
*
* Passkey API client for WebAuthn-based passwordless authentication.
*
* - `signup(options)` — register a new user and create a passkey credential
* - `login(options?)` — authenticate an existing user via passkey assertion
*
* Both methods exchange the WebAuthn credential for Auth0 tokens and update
* `isAuthenticated` / `user` in the same way as `loginWithPopup`.
*/
passkey: PasskeyApiClient;
}

/**
Expand Down Expand Up @@ -429,6 +446,10 @@ export const initialContext = {
verify: stub,
getEnrollmentFactors: stub,
} as unknown as MfaApiClient,
passkey: {
signup: stub,
login: stub,
} as unknown as PasskeyApiClient,
};

/**
Expand Down
48 changes: 47 additions & 1 deletion src/auth0-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ import {
ConnectAccountRedirectResult,
ResponseType,
CustomTokenExchangeOptions,
TokenEndpointResponse
TokenEndpointResponse,
type PasskeyApiClient,
type PasskeySignupOptions,
type PasskeyLoginOptions
} from '@auth0/auth0-spa-js';
import Auth0Context, {
Auth0ContextInterface,
Expand Down Expand Up @@ -391,6 +394,47 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs

const mfa = useMemo(() => client.mfa, [client]);

const passkeySignup = useCallback(
async (options: PasskeySignupOptions): Promise<TokenEndpointResponse> => {
let tokenResponse;
try {
tokenResponse = await client.passkey.signup(options);
} catch (error) {
throw tokenError(error);
} finally {
dispatch({
type: 'GET_ACCESS_TOKEN_COMPLETE',
user: await client.getUser(),
});
}
return tokenResponse;
},
[client]
);

const passkeyLogin = useCallback(
async (options?: PasskeyLoginOptions): Promise<TokenEndpointResponse> => {
let tokenResponse;
try {
tokenResponse = await client.passkey.login(options);
} catch (error) {
throw tokenError(error);
} finally {
dispatch({
type: 'GET_ACCESS_TOKEN_COMPLETE',
user: await client.getUser(),
});
}
return tokenResponse;
},
[client]
);

const passkey = useMemo(
() => ({ signup: passkeySignup, login: passkeyLogin }) as unknown as PasskeyApiClient,
[passkeySignup, passkeyLogin]
);

const contextValue = useMemo<Auth0ContextInterface<TUser>>(() => {
return {
...state,
Expand All @@ -411,6 +455,7 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
createFetcher,
getConfiguration,
mfa,
passkey,
};
}, [
state,
Expand All @@ -431,6 +476,7 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
createFetcher,
getConfiguration,
mfa,
passkey,
]);

return <context.Provider value={contextValue}>{children}</context.Provider>;
Expand Down
16 changes: 16 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ export {
MfaChallengeError,
MfaVerifyError,
MfaEnrollmentFactorsError,
// Passkey Errors
PasskeyError,
PasskeyRegisterError,
PasskeyChallengeError,
PasskeyGetTokenError,
} from '@auth0/auth0-spa-js';
export type {
FetcherConfig,
Expand All @@ -76,5 +81,16 @@ export type {
ChallengeResponse,
VerifyParams,
EnrollmentFactor,
// Passkey Types
PasskeyApiClient,
PasskeySignupOptions,
PasskeyLoginOptions,
PasskeyCredentialResponse,
PasskeySignupChallengeOptions,
PasskeySignupChallengeResponse,
PasskeyLoginChallengeOptions,
PasskeyLoginChallengeResponse,
PasskeyCreationOptions,
PasskeyRequestOptions,
} from '@auth0/auth0-spa-js';
export { OAuthError } from './errors';
Loading