diff --git a/EXAMPLES-WEB.md b/EXAMPLES-WEB.md index c4a709d0a..c6fded61d 100644 --- a/EXAMPLES-WEB.md +++ b/EXAMPLES-WEB.md @@ -282,3 +282,9 @@ const credentials = await auth0.mfa.verify({ The web platform supports direct authentication grants including `auth.passwordRealm()`, `auth.createUser()`, `auth.resetPassword()`, and the MFA Flexible Factors Grant. These methods make direct HTTP calls to the Auth0 API. Token refresh is handled automatically by `credentialsManager.getCredentials()` on the web. The `auth.refreshToken()` method is not available. + +### My Account API (Web) + +The [My Account API](./EXAMPLES.md#my-account-api) is supported on the web platform. The `auth0.myAccount` client works the same way as on native, so the examples in [EXAMPLES.md](./EXAMPLES.md#my-account-api) apply. Passkey enrollment on the web uses the browser's [WebAuthn](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API) APIs instead of a native passkey module. + +When DPoP is enabled (the default), a My Account call on the web only succeeds when the supplied access token was issued by the same client instance, because the DPoP proof is signed with that client's keypair. When the client is not configured with DPoP, plain bearer tokens are used and any valid access token works. diff --git a/EXAMPLES.md b/EXAMPLES.md index 09cd04839..795a015cd 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1497,11 +1497,14 @@ The My Account API allows authenticated users to manage their own authentication Access the My Account client via the `myAccount` property from `useAuth0()` or the `Auth0` class instance. +The My Account API is supported on Native (iOS/Android) and Web. The same `myAccount` API is used on all platforms; only the passkey credential ceremony differs (native passkey module vs. the browser's WebAuthn APIs). On Web, when DPoP is enabled (the default), the supplied access token must have been issued by the same client instance, since the DPoP proof is signed with that client's keypair; when the client is not configured with DPoP, plain bearer tokens are used and any valid access token works. + ### Prerequisites - A [custom domain](https://auth0.com/docs/customize/custom-domains) must be configured on your Auth0 tenant - **iOS**: Associated Domains entitlement must be configured with `webcredentials:` for passkey support - **Android**: App Links must be set up with your custom domain via an `assetlinks.json` file for passkey support +- **Web**: passkey enrollment uses the browser's [WebAuthn](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API) APIs; no additional platform setup is required - The user must be authenticated - An access token with the appropriate My Account API scopes is required: - `read:me:authentication_methods` diff --git a/README.md b/README.md index 98a6c2791..198e98772 100644 --- a/README.md +++ b/README.md @@ -900,36 +900,47 @@ try { This library provides a unified API across Native (iOS/Android) and Web platforms. However, due to security models and underlying technology, not all features are available on every platform. -| Feature / Method Category | Native (iOS/Android) | Web (Browser) | Notes & Rationale | -| ------------------------------------------ | :------------------: | :-----------: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Web Authentication** | | | --- | -| `webAuth.authorize()` | ✅ | ✅ | **Primary login method.** Uses `ASWebAuthenticationSession`/`Custom Tabs` on Native and `loginWithRedirect` on Web. | -| `webAuth.clearSession()` | ✅ | ✅ | **Primary logout method.** Clears the session cookie on the server via a browser redirect. | -| `webAuth.handleRedirectCallback()` | ❌ | ✅ | **Web-only.** Manually processes the callback from Auth0. Handled automatically when using the `Auth0Provider` hook. | -| **Credential Management** | | | --- | -| `credentialsManager.getCredentials()` | ✅ | ✅ | Retrieves stored tokens. On Native, it uses the secure Keychain/Keystore. On Web, it uses the `@auth0/auth0-spa-js` cache and `getTokenSilently`. | -| `credentialsManager.hasValidCredentials()` | ✅ | ✅ | Checks for a valid local session. | -| `credentialsManager.saveCredentials()` | ✅ | ❌ | **Native-only.** Manually saving credentials is required on Native. On Web, this is handled automatically by the underlying SPA SDK and is a no-op. | -| `credentialsManager.clearCredentials()` | ✅ | ✅ | Clears locally stored tokens. On Web, this performs a "local-only" logout. | -| **Direct Authentication Grants** | | | --- | -| `auth.passwordRealm()` | ✅ | ❌ | **Not supported on Web for security reasons.** The Resource Owner Password Grant exposes credentials to the browser and is not recommended for Single Page Applications. | -| `auth.passwordless...()` | ✅ | ❌ | **Not supported on Web.** Passwordless flows on the web should be configured via Universal Login and initiated with `webAuth.authorize()`. | -| `auth.loginWith...()` (OTP/SMS etc) | ✅ | ❌ | **Not supported on Web.** These direct grant flows are not secure for public clients like browsers. | -| **Passkeys** | | | --- | -| `passkeySignupChallenge()` | ✅ | ❌ | **Native-only.** Gets a WebAuthn registration challenge from Auth0. Requires iOS 16.6+ or Android API 28+. | -| `passkeyLoginChallenge()` | ✅ | ❌ | **Native-only.** Gets a WebAuthn assertion challenge from Auth0. Requires iOS 16.6+ or Android API 28+. | -| `getTokenByPasskey()` | ✅ | ❌ | **Native-only.** Exchanges a passkey credential response for Auth0 tokens. Requires iOS 16.6+ or Android API 28+. | -| **Token & User Management** | | | --- | -| `auth.refreshToken()` | ✅ | ❌ | **Not supported on Web.** Token refresh is handled automatically by `getCredentials()` via `getTokenSilently()` on the web. | -| `auth.userInfo()` | ✅ | ✅ | Fetches the user's profile from the `/userinfo` endpoint using an access token. | -| `auth.createUser()` | ✅ | ✅ | Calls the `/dbconnections/signup` endpoint. Works on both platforms. | -| `auth.resetPassword()` | ✅ | ✅ | Calls the `/dbconnections/change_password` endpoint. Works on both platforms. | -| `users(token).patchUser()` | ✅ | ✅ | Calls the Management API. Works on any platform with a valid token, but use with caution in the browser. | -| **MFA Flexible Factors Grant** | | | --- | -| `mfa.getAuthenticators()` | ✅ | ✅ | Lists enrolled MFA authenticators for the user. | -| `mfa.enroll()` | ✅ | ✅ | Enrolls a new MFA factor (OTP, SMS, email, voice, push). | -| `mfa.challenge()` | ✅ | ✅ | Triggers an MFA challenge for an enrolled authenticator. | -| `mfa.verify()` | ✅ | ✅ | Verifies an MFA code (OTP, OOB, recovery) and returns credentials. | +| Feature / Method Category | Native (iOS/Android) | Web (Browser) | Notes & Rationale | +| ---------------------------------------------------------------------- | :------------------: | :-----------: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Web Authentication** | | | --- | +| `webAuth.authorize()` | ✅ | ✅ | **Primary login method.** Uses `ASWebAuthenticationSession`/`Custom Tabs` on Native and `loginWithRedirect` on Web. | +| `webAuth.clearSession()` | ✅ | ✅ | **Primary logout method.** Clears the session cookie on the server via a browser redirect. | +| `webAuth.handleRedirectCallback()` | ❌ | ✅ | **Web-only.** Manually processes the callback from Auth0. Handled automatically when using the `Auth0Provider` hook. | +| **Credential Management** | | | --- | +| `credentialsManager.getCredentials()` | ✅ | ✅ | Retrieves stored tokens. On Native, it uses the secure Keychain/Keystore. On Web, it uses the `@auth0/auth0-spa-js` cache and `getTokenSilently`. | +| `credentialsManager.hasValidCredentials()` | ✅ | ✅ | Checks for a valid local session. | +| `credentialsManager.saveCredentials()` | ✅ | ❌ | **Native-only.** Manually saving credentials is required on Native. On Web, this is handled automatically by the underlying SPA SDK and is a no-op. | +| `credentialsManager.clearCredentials()` | ✅ | ✅ | Clears locally stored tokens. On Web, this performs a "local-only" logout. | +| **Direct Authentication Grants** | | | --- | +| `auth.passwordRealm()` | ✅ | ❌ | **Not supported on Web for security reasons.** The Resource Owner Password Grant exposes credentials to the browser and is not recommended for Single Page Applications. | +| `auth.passwordless...()` | ✅ | ❌ | **Not supported on Web.** Passwordless flows on the web should be configured via Universal Login and initiated with `webAuth.authorize()`. | +| `auth.loginWith...()` (OTP/SMS etc) | ✅ | ❌ | **Not supported on Web.** These direct grant flows are not secure for public clients like browsers. | +| **Passkeys** | | | --- | +| `passkeySignupChallenge()` | ✅ | ❌ | **Native-only.** Gets a WebAuthn registration challenge from Auth0. Requires iOS 16.6+ or Android API 28+. | +| `passkeyLoginChallenge()` | ✅ | ❌ | **Native-only.** Gets a WebAuthn assertion challenge from Auth0. Requires iOS 16.6+ or Android API 28+. | +| `getTokenByPasskey()` | ✅ | ❌ | **Native-only.** Exchanges a passkey credential response for Auth0 tokens. Requires iOS 16.6+ or Android API 28+. | +| **Token & User Management** | | | --- | +| `auth.refreshToken()` | ✅ | ❌ | **Not supported on Web.** Token refresh is handled automatically by `getCredentials()` via `getTokenSilently()` on the web. | +| `auth.userInfo()` | ✅ | ✅ | Fetches the user's profile from the `/userinfo` endpoint using an access token. | +| `auth.createUser()` | ✅ | ✅ | Calls the `/dbconnections/signup` endpoint. Works on both platforms. | +| `auth.resetPassword()` | ✅ | ✅ | Calls the `/dbconnections/change_password` endpoint. Works on both platforms. | +| `users(token).patchUser()` | ✅ | ✅ | Calls the Management API. Works on any platform with a valid token, but use with caution in the browser. | +| **MFA Flexible Factors Grant** | | | --- | +| `mfa.getAuthenticators()` | ✅ | ✅ | Lists enrolled MFA authenticators for the user. | +| `mfa.enroll()` | ✅ | ✅ | Enrolls a new MFA factor (OTP, SMS, email, voice, push). | +| `mfa.challenge()` | ✅ | ✅ | Triggers an MFA challenge for an enrolled authenticator. | +| `mfa.verify()` | ✅ | ✅ | Verifies an MFA code (OTP, OOB, recovery) and returns credentials. | +| **My Account API** | | | --- | +| `myAccount.getFactors()` | ✅ | ✅ | Lists the factors available for enrollment. | +| `myAccount.getAuthenticationMethods()` | ✅ | ✅ | Lists the user's enrolled authentication methods. | +| `myAccount.getAuthenticationMethodById()` | ✅ | ✅ | Retrieves a single enrolled authentication method by id. | +| `myAccount.updateAuthenticationMethodById()` | ✅ | ✅ | Updates an enrolled authentication method (e.g. name, preferred method). | +| `myAccount.deleteAuthenticationMethodById()` | ✅ | ✅ | Removes an enrolled authentication method. | +| `myAccount.enrollPhone/Email/TOTP/PushNotification/RecoveryCode()` | ✅ | ✅ | Starts an enrollment challenge for the given factor. | +| `myAccount.confirmPhoneEnrollment()` (and other `confirm...`) | ✅ | ✅ | Confirms an enrollment challenge and returns the enrolled authentication method. | +| `myAccount.passkeyEnrollmentChallenge()` / `myAccount.enrollPasskey()` | ✅ | ✅ | Enrolls a passkey as an authentication method. On Web, the browser's WebAuthn APIs handle the credential ceremony. | + +> **Note on DPoP (Web):** When the client is configured with DPoP (the default), My Account calls on Web only succeed when the supplied access token was issued by the same client instance, because the DPoP proof is signed with that client's keypair. When the client is not configured with DPoP, plain bearer tokens are used and any valid access token works. ## Troubleshooting diff --git a/example/src/App.web.tsx b/example/src/App.web.tsx index 0d9f9ccae..b53c1a94e 100644 --- a/example/src/App.web.tsx +++ b/example/src/App.web.tsx @@ -17,6 +17,9 @@ import Auth0, { MfaError, MfaErrorCodes, MfaFactorType, + MyAccountError, + PasskeyError, + PreferredAuthenticationMethods, } from 'react-native-auth0'; import type { MfaAuthenticator, @@ -29,6 +32,14 @@ import Button from './components/Button'; import Header from './components/Header'; import Result from './components/Result'; import LabeledInput from './components/LabeledInput'; +import { createWebPasskey } from './passkey/webPasskey'; + +// My Account API is served from the `/me/` audience of the tenant. MRRT +// (Multi-Resource Refresh Tokens) lets a single web session mint an access +// token for this audience via `getApiCredentials` without a fresh redirect. +const MY_ACCOUNT_AUDIENCE = `https://${config.domain}/me/`; +const MY_ACCOUNT_SCOPE = + 'read:me:authentication_methods delete:me:authentication_methods update:me:authentication_methods read:me:factors create:me:authentication_methods'; type MfaStep = | 'idle' @@ -52,12 +63,14 @@ const HooksAuthContent = (): React.JSX.Element => { error, isLoading, getCredentials, + getApiCredentials, createUser, resetPassword, loginWithPasswordRealm, customTokenExchange, mfa, users, + myAccount, } = useAuth0(); const [result, setResult] = useState(null); @@ -108,6 +121,27 @@ const HooksAuthContent = (): React.JSX.Element => { const [verifyScope, setVerifyScope] = useState(''); const [verifyAudience, setVerifyAudience] = useState(''); + // My Account state + const [maResult, setMaResult] = useState(null); + const [maError, setMaError] = useState(null); + const [maLoading, setMaLoading] = useState(false); + const [maToken, setMaToken] = useState(null); + const [phoneNumber, setPhoneNumber] = useState(''); + const [emailAddress, setEmailAddress] = useState(''); + const [otpCode, setOtpCode] = useState(''); + const [methodId, setMethodId] = useState(''); + const [methodName, setMethodName] = useState(''); + const [enrollmentState, setEnrollmentState] = useState<{ + id: string; + authSession: string; + kind: 'phone' | 'email' | 'totp' | 'recovery'; + } | null>(null); + const [passkeyChallenge, setPasskeyChallenge] = useState<{ + authenticationMethodId: string; + authSession: string; + authParamsPublicKey: Record; + } | null>(null); + const resetMfaWizard = () => { setMfaStep('idle'); setAuthenticators([]); @@ -282,6 +316,222 @@ const HooksAuthContent = (): React.JSX.Element => { } }; + // --- My Account helpers --- + + // Mint the `/me/` access token once via MRRT and cache it in state. Every + // My Account call below reuses this token, so this button must be pressed + // before any other My Account action. + const onGetMyAccountToken = async () => { + setMaResult(null); + setMaError(null); + setMaLoading(true); + try { + const credentials = await getApiCredentials( + MY_ACCOUNT_AUDIENCE, + MY_ACCOUNT_SCOPE + ); + setMaToken(credentials.accessToken); + setMaResult({ + step: 'getApiCredentials', + audience: MY_ACCOUNT_AUDIENCE, + accessToken: credentials.accessToken, + expiresAt: credentials.expiresAt, + scope: credentials.scope, + }); + } catch (e) { + setMaError(e as Error); + } finally { + setMaLoading(false); + } + }; + + const runMyAccount = async ( + action: (accessToken: string) => Promise + ) => { + if (!maToken) { + setMaResult(null); + setMaError(new Error('Get the My Account token (MRRT) first.')); + return; + } + setMaResult(null); + setMaError(null); + setMaLoading(true); + try { + const response = await action(maToken); + setMaResult(response ?? { success: true }); + } catch (e) { + // MyAccountError / PasskeyError carry richer fields than a plain message. + if (e instanceof MyAccountError) { + setMaError( + new Error( + `[${e.statusCode ?? ''}] ${e.title ?? 'My Account Error'}: ${ + e.detail ?? e.message + }` + ) + ); + } else if (e instanceof PasskeyError) { + setMaError(new Error(`[${e.type}] ${e.message}`)); + } else { + setMaError(e as Error); + } + } finally { + setMaLoading(false); + } + }; + + const onEnrollPhone = () => + runMyAccount(async (accessToken) => { + const challenge = await myAccount.enrollPhone({ + accessToken, + phoneNumber: phoneNumber.trim(), + preferredAuthenticationMethod: PreferredAuthenticationMethods.SMS, + }); + setEnrollmentState({ ...challenge, kind: 'phone' }); + return { step: 'enrollPhone', ...challenge }; + }); + + const onEnrollEmail = () => + runMyAccount(async (accessToken) => { + const challenge = await myAccount.enrollEmail({ + accessToken, + emailAddress: emailAddress.trim(), + }); + setEnrollmentState({ ...challenge, kind: 'email' }); + return { step: 'enrollEmail', ...challenge }; + }); + + const onEnrollTOTP = () => + runMyAccount(async (accessToken) => { + const challenge = await myAccount.enrollTOTP({ accessToken }); + setEnrollmentState({ + id: challenge.id, + authSession: challenge.authSession, + kind: 'totp', + }); + return { + step: 'enrollTOTP', + id: challenge.id, + barcodeUri: challenge.barcodeUri, + manualInputCode: challenge.manualInputCode, + }; + }); + + const onEnrollRecoveryCode = () => + runMyAccount(async (accessToken) => { + const challenge = await myAccount.enrollRecoveryCode({ accessToken }); + setEnrollmentState({ + id: challenge.id, + authSession: challenge.authSession, + kind: 'recovery', + }); + return { + step: 'enrollRecoveryCode', + id: challenge.id, + recoveryCode: challenge.recoveryCode, + }; + }); + + const onConfirmEnrollment = () => + runMyAccount(async (accessToken) => { + if (!enrollmentState) { + throw new Error('Start an enrollment first.'); + } + let method; + if (enrollmentState.kind === 'recovery') { + method = await myAccount.confirmRecoveryCodeEnrollment({ + accessToken, + id: enrollmentState.id, + authSession: enrollmentState.authSession, + }); + } else { + const confirmByKind = { + phone: myAccount.confirmPhoneEnrollment, + email: myAccount.confirmEmailEnrollment, + totp: myAccount.confirmTOTPEnrollment, + }; + method = await confirmByKind[enrollmentState.kind].call(myAccount, { + accessToken, + id: enrollmentState.id, + authSession: enrollmentState.authSession, + otpCode: otpCode.trim(), + }); + } + setEnrollmentState(null); + setOtpCode(''); + return { step: 'confirmEnrollment', ...method }; + }); + + const onPasskeyChallenge = () => + runMyAccount(async (accessToken) => { + const challenge = await myAccount.passkeyEnrollmentChallenge({ + accessToken, + }); + setPasskeyChallenge(challenge); + return { + step: 'passkeyEnrollmentChallenge', + authenticationMethodId: challenge.authenticationMethodId, + authSession: challenge.authSession, + }; + }); + + const onPasskeyVerify = () => + runMyAccount(async (accessToken) => { + if (!passkeyChallenge) { + throw new Error('Run the passkey challenge first.'); + } + const authResponse = await createWebPasskey( + passkeyChallenge.authParamsPublicKey + ); + const method = await myAccount.enrollPasskey({ + accessToken, + authenticationMethodId: passkeyChallenge.authenticationMethodId, + authSession: passkeyChallenge.authSession, + authResponse, + authParamsPublicKey: passkeyChallenge.authParamsPublicKey, + }); + setPasskeyChallenge(null); + return { step: 'enrollPasskey', ...method }; + }); + + const onGetFactors = () => + runMyAccount(async (accessToken) => { + const factors = await myAccount.getFactors({ accessToken }); + return { step: 'getFactors', factors }; + }); + + const onGetAuthenticationMethods = () => + runMyAccount(async (accessToken) => { + const methods = await myAccount.getAuthenticationMethods({ accessToken }); + return { + step: 'getAuthenticationMethods', + count: methods.length, + methods, + }; + }); + + const onUpdateMethod = () => + runMyAccount(async (accessToken) => { + const method = await myAccount.updateAuthenticationMethodById({ + accessToken, + id: methodId.trim(), + name: methodName.trim() || undefined, + }); + return { step: 'updateAuthenticationMethodById', ...method }; + }); + + const onDeleteMethod = () => + runMyAccount(async (accessToken) => { + await myAccount.deleteAuthenticationMethodById({ + accessToken, + id: methodId.trim(), + }); + setMethodId(''); + return { + step: 'deleteAuthenticationMethodById', + deleted: methodId.trim(), + }; + }); + if (isLoading) { return ( @@ -296,23 +546,26 @@ const HooksAuthContent = (): React.JSX.Element => { {error && } {user ? ( - - Welcome, {user.name}! - -