diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index f6fd1a69..0c841437 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -1,70 +1,70 @@ name: PUBLISH DOCS on: - workflow_dispatch: - workflow_call: - # or set up your own custom triggers + workflow_dispatch: + workflow_call: + # or set up your own custom triggers permissions: - contents: write # allows the 'Commit' step without tokens + contents: write # allows the 'Commit' step without tokens jobs: - get_history: # create an artifact from the existing documentation builds - runs-on: ubuntu-latest - steps: - - name: get the gh-pages repo - uses: actions/checkout@v6 - with: - ref: gh-pages + get_history: # create an artifact from the existing documentation builds + runs-on: ubuntu-latest + steps: + - name: get the gh-pages repo + uses: actions/checkout@v6 + with: + ref: gh-pages - - name: remove all symbolic links from root if present - run: | - find . -maxdepth 1 -type l -delete + - name: remove all symbolic links from root if present + run: | + find . -maxdepth 1 -type l -delete - - name: tar the existing docs from root - run: | - tar -cvf documentation.tar ./ + - name: tar the existing docs from root + run: | + tar -cvf documentation.tar ./ - - name: create a document artifact - uses: actions/upload-artifact@v5 - with: - name: documentation - path: documentation.tar - retention-days: 1 + - name: create a document artifact + uses: actions/upload-artifact@v5 + with: + name: documentation + path: documentation.tar + retention-days: 1 - build_and_deploy: # builds the distribution and then the documentation - needs: get_history - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Checkout src - uses: actions/checkout@v6 - with: - token: ${{ github.token }} + build_and_deploy: # builds the distribution and then the documentation + needs: get_history + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout src + uses: actions/checkout@v6 + with: + token: ${{ github.token }} - - name: Download the existing documents artifact - uses: actions/download-artifact@v6 - with: - name: documentation - - run: rm -rf ./docs # delete previous docs folder present - - run: mkdir ./docs # create an empty docs folder - - run: tar -xf documentation.tar -C ./docs - - run: rm -f documentation.tar + - name: Download the existing documents artifact + uses: actions/download-artifact@v6 + with: + name: documentation + - run: rm -rf ./docs # delete previous docs folder present + - run: mkdir ./docs # create an empty docs folder + - run: tar -xf documentation.tar -C ./docs + - run: rm -f documentation.tar - - name: Setup - uses: ./.github/actions/setup + - name: Setup + uses: ./.github/actions/setup - - name: Build documents - run: yarn docs #set up 'docs' build script in your package.json + - name: Build documents + run: yarn docs #set up 'docs' build script in your package.json - - name: Remove all the symbolic links from docs folder - run: find ./docs -type l -delete + - name: Remove all the symbolic links from docs folder + run: find ./docs -type l -delete - - name: Run cleanup and manage document versions - run: node scripts/manage-doc-versions.js + - name: Run cleanup and manage document versions + run: node scripts/manage-doc-versions.js - - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v4 - with: - github_token: ${{ github.token }} - publish_dir: ./docs - keep_files: false + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ github.token }} + publish_dir: ./docs + keep_files: false diff --git a/EXAMPLES.md b/EXAMPLES.md index 93fe387b..03876d0f 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -25,6 +25,12 @@ - [Handling DPoP token migration](#handling-dpop-token-migration) - [Checking token type](#checking-token-type) - [Handling nonce errors](#handling-nonce-errors) +- [Multi-Resource Refresh Tokens (MRRT)](#multi-resource-refresh-tokens-mrrt) + - [Overview](#mrrt-overview) + - [Prerequisites](#mrrt-prerequisites) + - [Using MRRT with Hooks](#using-mrrt-with-hooks) + - [Using MRRT with Auth0 Class](#using-mrrt-with-auth0-class) + - [Web Platform Configuration](#web-platform-configuration) - [Bot Protection](#bot-protection) - [Domain Switching](#domain-switching) - [Android](#android) @@ -304,6 +310,128 @@ auth0.webAuth If the URL doesn't contain the expected values, an error will be raised through the provided callback. +## Multi-Resource Refresh Tokens (MRRT) + +### MRRT Overview + +Multi-Resource Refresh Tokens (MRRT) allow your application to obtain access tokens for multiple APIs using a single refresh token. This is useful when your application needs to access multiple backend services, each identified by a different audience. + +### MRRT Prerequisites + +Before using MRRT, ensure: + +1. **MRRT is enabled on your Auth0 tenant** - Contact Auth0 support or enable it through the Auth0 Dashboard +2. **Request `offline_access` scope during login** - This ensures a refresh token is issued +3. **Configure your APIs in Auth0 Dashboard** - Each API you want to access should be registered with its own audience identifier + +### Using MRRT with Hooks + +```tsx +import { useAuth0 } from 'react-native-auth0'; + +function MyComponent() { + const { authorize, getApiCredentials, clearApiCredentials } = useAuth0(); + + const login = async () => { + // Login with offline_access to get a refresh token + await authorize({ + scope: 'openid profile email offline_access', + audience: 'https://primary-api.example.com', + }); + }; + + const getFirstApiToken = async () => { + try { + // Get credentials for the first API + const credentials = await getApiCredentials( + 'https://first-api.example.com', + 'read:data write:data' + ); + console.log('First API Access Token:', credentials.accessToken); + console.log('Expires At:', new Date(credentials.expiresAt * 1000)); + } catch (error) { + console.error('Error:', error); + } + }; + + const getSecondApiToken = async () => { + try { + // Get credentials for a different API using the same refresh token + const credentials = await getApiCredentials( + 'https://second-api.example.com', + 'read:reports' + ); + console.log('Second API Access Token:', credentials.accessToken); + } catch (error) { + console.error('Error:', error); + } + }; + + const clearFirstApiCache = async () => { + // Clear cached credentials for a specific API + await clearApiCredentials('https://first-api.example.com'); + }; + + return ( + // Your UI components + ); +} +``` + +### Using MRRT with Auth0 Class + +```js +import Auth0 from 'react-native-auth0'; + +const auth0 = new Auth0({ + domain: 'YOUR_AUTH0_DOMAIN', + clientId: 'YOUR_AUTH0_CLIENT_ID', +}); + +// Login with offline_access scope +await auth0.webAuth.authorize({ + scope: 'openid profile email offline_access', + audience: 'https://primary-api.example.com', +}); + +// Get credentials for a specific API +const apiCredentials = await auth0.credentialsManager.getApiCredentials( + 'https://first-api.example.com', + 'read:data write:data' +); + +console.log('Access Token:', apiCredentials.accessToken); +console.log('Token Type:', apiCredentials.tokenType); +console.log('Expires At:', apiCredentials.expiresAt); +console.log('Scope:', apiCredentials.scope); + +// Clear cached credentials for a specific API +await auth0.credentialsManager.clearApiCredentials( + 'https://first-api.example.com' +); +``` + +### Web Platform Configuration + +On the **web platform**, you must explicitly enable MRRT support in the `Auth0Provider`: + +```tsx +import { Auth0Provider } from 'react-native-auth0'; + +function App() { + return ( + + + + ); +} +``` + ## Bot Protection If you are using the [Bot Protection](https://auth0.com/docs/anomaly-detection/bot-protection) feature and performing database login/signup via the Authentication API, you need to handle the `requires_verification` error. It indicates that the request was flagged as suspicious and an additional verification step is necessary to log the user in. That verification step is web-based, so you need to use Universal Login to complete it. diff --git a/FAQ.md b/FAQ.md index 3a465279..9001243f 100644 --- a/FAQ.md +++ b/FAQ.md @@ -391,7 +391,7 @@ function App() { const onLogin = async () => { await authorize({ audience: AUDIENCE, - scope: 'openid profile email offline_access' + scope: 'openid profile email offline_access', }); }; @@ -400,7 +400,7 @@ function App() { const credentials = await getCredentials( 'openid profile email offline_access', 0, - { audience: AUDIENCE } // ← Must include audience here! + { audience: AUDIENCE } // ← Must include audience here! ); console.log('JWT Access Token:', credentials.accessToken); }; @@ -427,7 +427,7 @@ Define your auth configuration once and reuse it: ```javascript const AUTH_CONFIG = { audience: 'https://your-api.example.com', - scope: 'openid profile email offline_access' + scope: 'openid profile email offline_access', }; // Login @@ -435,7 +435,7 @@ await authorize(AUTH_CONFIG); // Get credentials later (include audience in parameters) await getCredentials(AUTH_CONFIG.scope, 0, { - audience: AUTH_CONFIG.audience + audience: AUTH_CONFIG.audience, }); ``` @@ -444,13 +444,13 @@ await getCredentials(AUTH_CONFIG.scope, 0, { ```javascript const auth0 = new Auth0({ domain: 'YOUR_DOMAIN', - clientId: 'YOUR_CLIENT_ID' + clientId: 'YOUR_CLIENT_ID', }); // Login await auth0.webAuth.authorize({ audience: 'https://your-api.example.com', - scope: 'openid profile email offline_access' + scope: 'openid profile email offline_access', }); // Get credentials (must include audience) diff --git a/README.md b/README.md index 6b48b604..07c0ba14 100644 --- a/README.md +++ b/README.md @@ -212,9 +212,9 @@ To use the SDK with Expo, configure the app at build time by providing the `doma > :info: If you want to switch between multiple domains in your app, refer [here](https://github.com/auth0/react-native-auth0/blob/master/EXAMPLES.md#domain-switching) -| API | Description | -| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| domain | Mandatory: Provide the Auth0 domain that can be found at the [Application Settings](https://manage.auth0.com/#/applications) | +| API | Description | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| domain | Mandatory: Provide the Auth0 domain that can be found at the [Application Settings](https://manage.auth0.com/#/applications) | | customScheme | Optional: Custom scheme to build the callback URL with. The value provided here should be passed to the `customScheme` option parameter of the `authorize` and `clearSession` methods. The custom scheme should be a unique, all lowercase value with no special characters. To use Android App Links, set this value to `"https"`. | **Note:** When using `customScheme: "https"` for Android App Links, the plugin will automatically add `android:autoVerify="true"` to the intent-filter in your Android manifest to enable automatic verification of App Links. diff --git a/android/build.gradle b/android/build.gradle index 42e77318..ffcf375f 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -96,7 +96,7 @@ dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "androidx.browser:browser:1.2.0" - implementation 'com.auth0.android:auth0:3.10.0' + implementation 'com.auth0.android:auth0:3.11.0' } if (isNewArchitectureEnabled()) { diff --git a/android/src/main/java/com/auth0/react/A0Auth0Module.kt b/android/src/main/java/com/auth0/react/A0Auth0Module.kt index 79f2dde7..eed82a1e 100644 --- a/android/src/main/java/com/auth0/react/A0Auth0Module.kt +++ b/android/src/main/java/com/auth0/react/A0Auth0Module.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.content.Intent import androidx.fragment.app.FragmentActivity import com.auth0.android.Auth0 +import com.auth0.android.result.APICredentials import com.auth0.android.authentication.AuthenticationAPIClient import com.auth0.android.authentication.AuthenticationException import com.auth0.android.authentication.storage.CredentialsManagerException @@ -183,6 +184,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 try { val localAuthOptions = LocalAuthenticationOptionsParser.fromMap(options) secureCredentialsManager = SecureCredentialsManager( + authAPI, reactContext, auth0!!, SharedPreferencesStorage(reactContext), @@ -192,7 +194,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 promise.resolve(true) return } catch (e: Exception) { - secureCredentialsManager = getSecureCredentialsManagerWithoutBiometrics() + secureCredentialsManager = getSecureCredentialsManagerWithoutBiometrics(authAPI) promise.reject( BIOMETRICS_AUTHENTICATION_ERROR_CODE, "Failed to parse the Local Authentication Options, hence proceeding without Biometrics Authentication for handling Credentials" @@ -200,7 +202,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 return } } else { - secureCredentialsManager = getSecureCredentialsManagerWithoutBiometrics() + secureCredentialsManager = getSecureCredentialsManagerWithoutBiometrics(authAPI) promise.reject( BIOMETRICS_AUTHENTICATION_ERROR_CODE, "Biometrics Authentication for Handling Credentials are supported only on FragmentActivity, since a different activity is supplied, proceeding without it" @@ -209,7 +211,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 } } - secureCredentialsManager = getSecureCredentialsManagerWithoutBiometrics() + secureCredentialsManager = getSecureCredentialsManagerWithoutBiometrics(authAPI) promise.resolve(true) } @@ -290,6 +292,47 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 promise.resolve(secureCredentialsManager.hasValidCredentials(minTtl.toLong())) } + @ReactMethod + override fun getApiCredentials( + audience: String, + scope: String?, + minTtl: Double, + parameters: ReadableMap, + promise: Promise + ) { + val cleanedParameters = mutableMapOf() + parameters.toHashMap().forEach { (key, value) -> + value?.let { cleanedParameters[key] = it.toString() } + } + + UiThreadUtil.runOnUiThread { + secureCredentialsManager.getApiCredentials( + audience, + scope, + minTtl.toInt(), + cleanedParameters, + emptyMap(), // headers not supported from JS yet + object : com.auth0.android.callback.Callback { + override fun onSuccess(credentials: APICredentials) { + val map = ApiCredentialsParser.toMap(credentials) + promise.resolve(map) + } + + override fun onFailure(e: CredentialsManagerException) { + val errorCode = deduceErrorCode(e) + promise.reject(errorCode, e.message, e) + } + } + ) + } + } + + @ReactMethod + override fun clearApiCredentials(audience: String, promise: Promise) { + secureCredentialsManager.clearApiCredentials(audience) + promise.resolve(true) + } + override fun getConstants(): Map { return mapOf("bundleIdentifier" to reactContext.applicationInfo.packageName) } @@ -415,8 +458,9 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 promise.resolve(true) } - private fun getSecureCredentialsManagerWithoutBiometrics(): SecureCredentialsManager { + private fun getSecureCredentialsManagerWithoutBiometrics(authAPI: AuthenticationAPIClient): SecureCredentialsManager { return SecureCredentialsManager( + authAPI, reactContext, auth0!!, SharedPreferencesStorage(reactContext) diff --git a/android/src/main/java/com/auth0/react/ApiCredentialsParser.kt b/android/src/main/java/com/auth0/react/ApiCredentialsParser.kt new file mode 100644 index 00000000..b640d1b1 --- /dev/null +++ b/android/src/main/java/com/auth0/react/ApiCredentialsParser.kt @@ -0,0 +1,22 @@ +package com.auth0.react + +import com.auth0.android.result.APICredentials +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReadableMap + +object ApiCredentialsParser { + + private const val ACCESS_TOKEN_KEY = "accessToken" + private const val EXPIRES_AT_KEY = "expiresAt" + private const val SCOPE_KEY = "scope" + private const val TOKEN_TYPE_KEY = "tokenType" + + fun toMap(credentials: APICredentials): ReadableMap { + val map = Arguments.createMap() + map.putString(ACCESS_TOKEN_KEY, credentials.accessToken) + map.putDouble(EXPIRES_AT_KEY, credentials.expiresAt.time / 1000.0) + map.putString(SCOPE_KEY, credentials.scope) + map.putString(TOKEN_TYPE_KEY, credentials.type) + return map + } +} diff --git a/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt b/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt index 58c764ae..9f8ed786 100644 --- a/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt +++ b/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt @@ -51,6 +51,20 @@ abstract class A0Auth0Spec(context: ReactApplicationContext) : ReactContextBaseJ @DoNotStrip abstract fun clearCredentials(promise: Promise) + @ReactMethod + @DoNotStrip + abstract fun getApiCredentials( + audience: String, + scope: String?, + minTTL: Double, + parameters: ReadableMap, + promise: Promise + ) + + @ReactMethod + @DoNotStrip + abstract fun clearApiCredentials(audience: String, promise: Promise) + @ReactMethod @DoNotStrip abstract fun webAuth( diff --git a/eslint.config.mjs b/eslint.config.mjs index b5bb3924..349b8d96 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -36,7 +36,14 @@ export default defineConfig([ // TypeScript-specific configuration for type-checked rules { files: ['**/*.ts', '**/*.tsx'], - ignores: ['**/__tests__/**', '**/__mocks__/**', '**/*.spec.ts', '**/*.spec.tsx', '**/*.test.ts', '**/*.test.tsx'], + ignores: [ + '**/__tests__/**', + '**/__mocks__/**', + '**/*.spec.ts', + '**/*.spec.tsx', + '**/*.test.ts', + '**/*.test.tsx', + ], languageOptions: { parserOptions: { project: true, diff --git a/example/src/navigation/MainTabNavigator.tsx b/example/src/navigation/MainTabNavigator.tsx index fe465ed5..0a9c866f 100644 --- a/example/src/navigation/MainTabNavigator.tsx +++ b/example/src/navigation/MainTabNavigator.tsx @@ -5,11 +5,13 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import ProfileScreen from '../screens/hooks/Profile'; import ApiScreen from '../screens/hooks/Api'; import MoreScreen from '../screens/hooks/More'; +import CredentialsScreen from '../screens/hooks/CredentialsScreen'; export type MainTabParamList = { Profile: undefined; Api: undefined; More: undefined; + Credentials: undefined; }; const Tab = createBottomTabNavigator(); @@ -31,6 +33,7 @@ const MainTabNavigator = () => { component={ProfileScreen} // You can add icons here if desired /> + diff --git a/example/src/screens/class-based/ClassProfile.tsx b/example/src/screens/class-based/ClassProfile.tsx index d41b07af..744c6cc8 100644 --- a/example/src/screens/class-based/ClassProfile.tsx +++ b/example/src/screens/class-based/ClassProfile.tsx @@ -1,86 +1,195 @@ -import React, { useMemo } from 'react'; -import { SafeAreaView, ScrollView, View, StyleSheet } from 'react-native'; -import { useNavigation, RouteProp } from '@react-navigation/native'; -import type { StackNavigationProp } from '@react-navigation/stack'; +import React, { Component } from 'react'; +import { + SafeAreaView, + ScrollView, + View, + StyleSheet, + Text, + Alert, +} from 'react-native'; +import { RouteProp, NavigationProp } from '@react-navigation/native'; import { jwtDecode } from 'jwt-decode'; import auth0 from '../../api/auth0'; import Button from '../../components/Button'; import Header from '../../components/Header'; import UserInfo from '../../components/UserInfo'; -import { User } from 'react-native-auth0'; +import { User, Credentials, ApiCredentials } from 'react-native-auth0'; import type { ClassDemoStackParamList } from '../../navigation/ClassDemoNavigator'; +import LabeledInput from '../../components/LabeledInput'; +import config from '../../auth0-configuration'; +import Result from '../../components/Result'; type ProfileRouteProp = RouteProp; -type NavigationProp = StackNavigationProp< - ClassDemoStackParamList, - 'ClassProfile' ->; type Props = { route: ProfileRouteProp; + navigation: NavigationProp; }; -const ClassProfileScreen = ({ route }: Props) => { - const navigation = useNavigation(); - const { credentials } = route.params; +interface State { + user: User | null; + result: Credentials | ApiCredentials | object | boolean | null; + error: Error | null; + audience: string; +} - const user = useMemo(() => { +class ClassProfileScreen extends Component { + constructor(props: Props) { + super(props); + const user = this.decodeIdToken(props.route.params.credentials.idToken); + this.state = { + user, + result: null, + error: null, + audience: config.audience, + }; + } + + decodeIdToken = (idToken: string): User | null => { try { - return jwtDecode(credentials.idToken); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (e) { + return jwtDecode(idToken); + } catch { return null; } - }, [credentials.idToken]); + }; - const onLogout = async () => { + runTest = async (testFn: () => Promise, title: string) => { + this.setState({ error: null, result: null }); + try { + const res = await testFn(); + this.setState({ result: res ?? { success: `${title} completed` } }); + } catch (e) { + this.setState({ error: e as Error }); + } + }; + + onLogout = async () => { try { await auth0.webAuth.clearSession(); await auth0.credentialsManager.clearCredentials(); - navigation.goBack(); + this.props.navigation.goBack(); } catch (e) { - console.log('Logout error: ', e); + Alert.alert('Error', (e as Error).message); } }; - const onNavigateToApiTests = () => { - navigation.navigate('ClassApiTests', { - accessToken: credentials.accessToken, - }); - }; + render() { + const { user, result, error, audience } = this.state; + const { accessToken } = this.props.route.params.credentials; - return ( - -
- - -