diff --git a/EXAMPLES.md b/EXAMPLES.md index 0ab06e97..233a0228 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -17,6 +17,7 @@ - [Step-Up Authentication](#step-up-authentication) - [Native to Web SSO](#native-to-web-sso) - [Passkeys](#passkeys) +- [MyAccount API](#myaccount-api) ## Use with a Class Component @@ -1380,3 +1381,238 @@ try { > [!TIP] > Both `signup()` and `login()` throw an error if the user cancels the biometric prompt. Wrap calls in `try/catch` to handle cancellation, network failures, or misconfigured connections. + +## MyAccount API + +The MyAccount API lets you manage the current user's authentication methods, factors, and connected accounts directly from the SPA. + +> [!NOTE] +> The MyAccount API requires refresh tokens and MRRT if your app is configured with a custom API audience. DPoP is supported but optional. + +### Factors + +Get the list of MFA factors and their enabled status for the current user. + +```jsx +const { myAccount } = useAuth0(); + +const factors = await myAccount.getFactors(); +// [{ type: 'totp', usage: ['secondary'] }, { type: 'phone', usage: ['secondary'] }] +``` + +### Authentication Methods + +#### List All + +```jsx +const { myAccount } = useAuth0(); + +const methods = await myAccount.getAuthenticationMethods(); +``` + +#### Filter by Type + +```jsx +const passkeys = await myAccount.getAuthenticationMethods('passkey'); +``` + +#### Get by ID + +```jsx +const method = await myAccount.getAuthenticationMethod('am_abc123'); +``` + +#### Delete + +```jsx +await myAccount.deleteAuthenticationMethod('am_abc123'); +``` + +#### Update + +```jsx +// Rename any method +const updated = await myAccount.updateAuthenticationMethod('am_abc123', { + name: 'My Work Laptop' +}); +``` + +```jsx +// Change preferred delivery method for phone +const updated = await myAccount.updateAuthenticationMethod('am_abc123', { + preferred_authentication_method: 'voice' +}); +``` + +### Enrollment + +Enrollment is a two-step flow: get a challenge, then verify the credential. + +#### Passkey + +```jsx +const { myAccount } = useAuth0(); + +// Step 1: get the WebAuthn creation challenge +const challenge = await myAccount.enrollmentChallenge({ type: 'passkey' }); + +// Step 2: trigger the browser ceremony +const credential = await navigator.credentials.create({ + publicKey: { + ...challenge.authn_params_public_key, + challenge: base64urlToBuffer(challenge.authn_params_public_key.challenge), + user: { + ...challenge.authn_params_public_key.user, + id: base64urlToBuffer(challenge.authn_params_public_key.user.id) + } + } +}); + +// Step 3: verify and complete enrollment +const method = await myAccount.enrollmentVerify({ + type: 'passkey', + location: challenge.location, + auth_session: challenge.auth_session, + authn_response: serializeCredential(credential) +}); +``` + +#### Phone + +```jsx +// Step 1: request OTP to the phone number +const challenge = await myAccount.enrollmentChallenge({ + type: 'phone', + phone_number: '+15551234567', + preferred_authentication_method: 'sms' +}); + +// Step 2: verify with the OTP the user received +await myAccount.enrollmentVerify({ + type: 'phone', + location: challenge.location, + auth_session: challenge.auth_session, + otp_code: '123456' +}); +``` + +#### Email + +```jsx +const challenge = await myAccount.enrollmentChallenge({ + type: 'email', + email: 'user@example.com' +}); + +await myAccount.enrollmentVerify({ + type: 'email', + location: challenge.location, + auth_session: challenge.auth_session, + otp_code: '123456' +}); +``` + +#### TOTP + +```jsx +const challenge = await myAccount.enrollmentChallenge({ type: 'totp' }); +// challenge.barcode_uri — show this as a QR code for the user to scan +// challenge.manual_input_code — fallback manual entry code + +await myAccount.enrollmentVerify({ + type: 'totp', + location: challenge.location, + auth_session: challenge.auth_session, + otp_code: '123456' +}); +``` + +#### WebAuthn Platform / Roaming + +Same flow as [Passkey](#passkey) above — just change the `type`: + +```jsx +// Platform authenticator (e.g. Touch ID, Windows Hello) +const challenge = await myAccount.enrollmentChallenge({ type: 'webauthn-platform' }); + +// Roaming authenticator (e.g. a hardware security key) +const challenge = await myAccount.enrollmentChallenge({ type: 'webauthn-roaming' }); + +// The credential creation ceremony and verify call are identical to passkey +await myAccount.enrollmentVerify({ + type: 'webauthn-platform', // or 'webauthn-roaming' + location: challenge.location, + auth_session: challenge.auth_session, + authn_response: serializeCredential(credential) +}); +``` + +#### Push Notification + +```jsx +const challenge = await myAccount.enrollmentChallenge({ type: 'push-notification' }); +// challenge.barcode_uri — show this as a QR code to link the authenticator app + +// No OTP needed — user approves on their device +await myAccount.enrollmentVerify({ + type: 'push-notification', + location: challenge.location, + auth_session: challenge.auth_session +}); +``` + +#### Recovery Code + +```jsx +const challenge = await myAccount.enrollmentChallenge({ type: 'recovery-code' }); +// challenge.recovery_code — display this to the user to save securely + +// Verify just confirms the user has saved the code +await myAccount.enrollmentVerify({ + type: 'recovery-code', + location: challenge.location, + auth_session: challenge.auth_session +}); +``` + +#### Password + +```jsx +const challenge = await myAccount.enrollmentChallenge({ type: 'password' }); + +await myAccount.enrollmentVerify({ + type: 'password', + location: challenge.location, + auth_session: challenge.auth_session, + new_password: 'newSecurePassword123!' +}); +``` + +### Error Handling + +All MyAccount API errors throw `MyAccountApiError` with RFC 7807 fields. + +```jsx +import { MyAccountApiError } from '@auth0/auth0-react'; + +const { myAccount } = useAuth0(); + +try { + await myAccount.enrollmentChallenge({ type: 'passkey' }); +} catch (err) { + if (err instanceof MyAccountApiError) { + console.error(err.status, err.title, err.detail); + if (err.validation_errors) { + err.validation_errors.forEach(e => console.error(e.field, e.detail)); + } + } +} + +try { + await myAccount.deleteAuthenticationMethod('am_abc123'); +} catch (err) { + if (err instanceof MyAccountApiError) { + console.error(err.status, err.title, err.detail); + } +} +``` diff --git a/__mocks__/@auth0/auth0-spa-js.tsx b/__mocks__/@auth0/auth0-spa-js.tsx index b47cfb2c..9fbbc136 100644 --- a/__mocks__/@auth0/auth0-spa-js.tsx +++ b/__mocks__/@auth0/auth0-spa-js.tsx @@ -30,6 +30,14 @@ 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' })); +const myAccountGetFactors = jest.fn(() => Promise.resolve([])); +const myAccountGetAuthenticationMethods = jest.fn(() => Promise.resolve([])); +const myAccountGetAuthenticationMethod = jest.fn(() => Promise.resolve({ id: 'test-method-id' })); +const myAccountUpdateAuthenticationMethod = jest.fn(() => Promise.resolve({ id: 'test-method-id' })); +const myAccountDeleteAuthenticationMethod = jest.fn(() => Promise.resolve()); +const myAccountEnrollmentChallenge = jest.fn(() => Promise.resolve({ id: 'test-challenge-id', location: 'https://example.auth0.com/enroll', auth_session: 'test-auth-session', type: 'totp', barcode_uri: 'otpauth://totp/...' })); +const myAccountEnrollmentVerify = jest.fn(() => Promise.resolve({ id: 'test-method-id' })); + export const Auth0Client = jest.fn(() => { return { buildAuthorizeUrl, @@ -64,6 +72,15 @@ export const Auth0Client = jest.fn(() => { signup: passkeySignup, login: passkeyLogin, }, + myAccount: { + getFactors: myAccountGetFactors, + getAuthenticationMethods: myAccountGetAuthenticationMethods, + getAuthenticationMethod: myAccountGetAuthenticationMethod, + updateAuthenticationMethod: myAccountUpdateAuthenticationMethod, + deleteAuthenticationMethod: myAccountDeleteAuthenticationMethod, + enrollmentChallenge: myAccountEnrollmentChallenge, + enrollmentVerify: myAccountEnrollmentVerify, + }, }; }); @@ -79,4 +96,5 @@ 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; \ No newline at end of file +export const PasskeyGetTokenError = actual.PasskeyGetTokenError; +export const MyAccountApiError = actual.MyAccountApiError; diff --git a/__tests__/my-account.test.tsx b/__tests__/my-account.test.tsx new file mode 100644 index 00000000..8770b689 --- /dev/null +++ b/__tests__/my-account.test.tsx @@ -0,0 +1,145 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { Auth0Client, MyAccountApiError } from '@auth0/auth0-spa-js'; +import useAuth0 from '../src/use-auth0'; +import { createWrapper } from './helpers'; + +const clientMock = jest.mocked(new Auth0Client({ clientId: '', domain: '' })); + +describe('MyAccount API', () => { + describe('Basic Availability', () => { + it('should provide myAccount client through useAuth0', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => { + expect(result.current.myAccount).toBeDefined(); + }); + }); + + it('should provide all seven MyAccount methods', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => { + expect(result.current.myAccount.getFactors).toBeDefined(); + expect(result.current.myAccount.getAuthenticationMethods).toBeDefined(); + expect(result.current.myAccount.getAuthenticationMethod).toBeDefined(); + expect(result.current.myAccount.updateAuthenticationMethod).toBeDefined(); + expect(result.current.myAccount.deleteAuthenticationMethod).toBeDefined(); + expect(result.current.myAccount.enrollmentChallenge).toBeDefined(); + expect(result.current.myAccount.enrollmentVerify).toBeDefined(); + }); + }); + }); + + describe('Method Success Tests', () => { + it('should call myAccount.getFactors', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const factors = await result.current.myAccount.getFactors(); + expect(Array.isArray(factors)).toBe(true); + }); + + it('should call myAccount.getAuthenticationMethods without filter', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const methods = await result.current.myAccount.getAuthenticationMethods(); + expect(Array.isArray(methods)).toBe(true); + }); + + it('should call myAccount.getAuthenticationMethods with type filter', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const methods = await result.current.myAccount.getAuthenticationMethods('passkey'); + expect(Array.isArray(methods)).toBe(true); + expect(clientMock.myAccount.getAuthenticationMethods).toHaveBeenCalledWith('passkey'); + }); + + it('should call myAccount.getAuthenticationMethod', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const method = await result.current.myAccount.getAuthenticationMethod('test-method-id'); + expect(method.id).toBe('test-method-id'); + }); + + it('should call myAccount.updateAuthenticationMethod', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const updated = await result.current.myAccount.updateAuthenticationMethod('test-method-id', { name: 'My Passkey' }); + expect(updated.id).toBe('test-method-id'); + }); + + it('should call myAccount.deleteAuthenticationMethod', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await expect( + result.current.myAccount.deleteAuthenticationMethod('test-method-id') + ).resolves.toBeUndefined(); + }); + + it('should call myAccount.enrollmentChallenge', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const challenge = await result.current.myAccount.enrollmentChallenge({ type: 'totp' }); + expect(challenge.id).toBe('test-challenge-id'); + expect(challenge.auth_session).toBe('test-auth-session'); + }); + + it('should call myAccount.enrollmentVerify', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const verified = await result.current.myAccount.enrollmentVerify({ + type: 'totp', + location: 'https://example.auth0.com/enroll', + auth_session: 'test-auth-session', + otp_code: '123456', + }); + expect(verified.id).toBe('test-method-id'); + }); + }); + + describe('Error Handling', () => { + it('should propagate MyAccountApiError thrown by a method', async () => { + const error = new MyAccountApiError({ + type: 'https://auth0.com/docs/errors#insufficient-scope', + status: 403, + title: 'Forbidden', + detail: 'Insufficient scope to access this resource', + }); + clientMock.myAccount.getAuthenticationMethods.mockRejectedValueOnce(error); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await expect( + result.current.myAccount.getAuthenticationMethods() + ).rejects.toBeInstanceOf(MyAccountApiError); + }); + }); +}); diff --git a/package.json b/package.json index 55714d63..0fff0155 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,6 @@ "react-dom": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" }, "dependencies": { - "@auth0/auth0-spa-js": "^2.20.0" + "@auth0/auth0-spa-js": "^2.21.1" } } diff --git a/src/auth0-context.tsx b/src/auth0-context.tsx index a3e85b9d..fc65393d 100644 --- a/src/auth0-context.tsx +++ b/src/auth0-context.tsx @@ -15,7 +15,8 @@ import { CustomTokenExchangeOptions, TokenEndpointResponse, type MfaApiClient, - type PasskeyApiClient + type PasskeyApiClient, + type MyAccountApiClient } from '@auth0/auth0-spa-js'; import { createContext } from 'react'; import { AuthState, initialAuthState } from './auth-state'; @@ -407,6 +408,42 @@ export interface Auth0ContextInterface * `isAuthenticated` / `user` in the same way as `loginWithPopup`. */ passkey: PasskeyApiClient; + + /** + * ```js + * const { myAccount } = useAuth0(); + * const factors = await myAccount.getFactors(); + * ``` + * + * MyAccount API client for self-service account management operations. + * + * Provides access to methods for managing the authenticated user's authentication + * methods and factors: + * - `getFactors()` - List available authentication factors + * - `getAuthenticationMethods(type?)` - List enrolled authentication methods, optionally filtered by type + * - `getAuthenticationMethod(id)` - Get a specific authentication method by ID + * - `updateAuthenticationMethod(id, data)` - Update an authentication method (e.g. rename) + * - `deleteAuthenticationMethod(id)` - Remove an enrolled authentication method + * - `enrollmentChallenge(options)` - Initiate a two-step enrollment challenge + * - `enrollmentVerify(options)` - Complete a two-step enrollment by verifying the challenge + * + * @example + * ```js + * const { myAccount } = useAuth0(); + * + * // List all enrolled authentication methods + * const methods = await myAccount.getAuthenticationMethods(); + * + * // Enroll a new passkey + * const challenge = await myAccount.enrollmentChallenge({ type: 'passkey' }); + * const credential = await navigator.credentials.create({ publicKey: challenge.authn_params_public_key }); + * await myAccount.enrollmentVerify({ type: 'passkey', auth_session: challenge.auth_session, location: challenge.location, authn_response: credential }); + * + * // Remove an authentication method + * await myAccount.deleteAuthenticationMethod('method-id'); + * ``` + */ + myAccount: MyAccountApiClient; } /** @@ -450,6 +487,15 @@ export const initialContext = { signup: stub, login: stub, } as unknown as PasskeyApiClient, + myAccount: { + getFactors: stub, + getAuthenticationMethods: stub, + getAuthenticationMethod: stub, + updateAuthenticationMethod: stub, + deleteAuthenticationMethod: stub, + enrollmentChallenge: stub, + enrollmentVerify: stub, + } as unknown as MyAccountApiClient, }; /** diff --git a/src/auth0-provider.tsx b/src/auth0-provider.tsx index c579b154..90b78943 100644 --- a/src/auth0-provider.tsx +++ b/src/auth0-provider.tsx @@ -393,6 +393,7 @@ const Auth0Provider = (opts: Auth0ProviderOptions client.mfa, [client]); + const myAccount = useMemo(() => client.myAccount, [client]); const passkeySignup = useCallback( async (options: PasskeySignupOptions): Promise => { @@ -456,6 +457,7 @@ const Auth0Provider = (opts: Auth0ProviderOptions(opts: Auth0ProviderOptions{children}; diff --git a/src/index.tsx b/src/index.tsx index fe87ac18..639868b8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -62,6 +62,8 @@ export { PasskeyRegisterError, PasskeyChallengeError, PasskeyGetTokenError, + // MyAccount Errors + MyAccountApiError, } from '@auth0/auth0-spa-js'; export type { FetcherConfig, @@ -85,5 +87,14 @@ export type { PasskeyApiClient, PasskeySignupOptions, PasskeyLoginOptions, + // MyAccount Types + MyAccountApiClient, + AuthenticationMethod, + AuthenticationMethodType, + Factor, + UpdateAuthenticationMethodRequest, + EnrollmentChallengeOptions, + EnrollmentChallengeResponse, + EnrollmentVerifyOptions, } from '@auth0/auth0-spa-js'; export { OAuthError } from './errors';