diff --git a/A0Auth0.podspec b/A0Auth0.podspec index 42e42311..34af539b 100644 --- a/A0Auth0.podspec +++ b/A0Auth0.podspec @@ -16,7 +16,7 @@ Pod::Spec.new do |s| s.source_files = 'ios/**/*.{h,m,mm,swift}' s.requires_arc = true - s.dependency 'Auth0', '2.14' + s.dependency 'Auth0', '2.16' install_modules_dependencies(s) end diff --git a/EXAMPLES.md b/EXAMPLES.md index 5f56aa2b..ff6341cd 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -13,6 +13,12 @@ - [Set global headers during initialization](#set-global-headers-during-initialization) - [Using custom headers with Auth0Provider component](#using-custom-headers-with-auth0provider-component) - [Set request-specific headers](#set-request-specific-headers) +- [Biometric Authentication](#biometric-authentication) + - [Biometric Policy Types](#biometric-policy-types) + - [Using with Auth0Provider (Hooks)](#using-with-auth0provider-hooks) + - [Using with Auth0 Class](#using-with-auth0-class) + - [Platform-Specific Behavior](#platform-specific-behavior) + - [Migration from Previous Behavior](#migration-from-previous-behavior) - [Management API (Users)](#management-api-users) - [Patch user with user_metadata](#patch-user-with-user_metadata) - [Get full user profile](#get-full-user-profile) @@ -253,6 +259,116 @@ auth0.auth .catch(console.error); ``` +## Biometric Authentication + +> **Platform Support:** Native only (iOS/Android) + +Configure biometric authentication to protect credential access. The SDK supports four biometric policies that control when biometric prompts are shown. + +### Biometric Policy Types + +- **`BiometricPolicy.default`**: System-managed behavior. Reuses the same `LAContext` on iOS, allowing the system to optimize prompt frequency. May skip the biometric prompt if authentication was recently successful. + +- **`BiometricPolicy.always`**: Always requires biometric authentication on every credential access. Creates a fresh `LAContext` on iOS and uses the "Always" policy on Android to ensure a new prompt is shown. + +- **`BiometricPolicy.session`**: Requires biometric authentication only once per session. After successful authentication, credentials can be accessed without prompting for the specified timeout duration. + +- **`BiometricPolicy.appLifecycle`**: Similar to session policy, but persists for the app's lifecycle. Session remains valid until the app restarts or `clearCredentials()` is called. Default timeout is 1 hour (3600 seconds). + +### Using with Auth0Provider (Hooks) + +```jsx +import { + Auth0Provider, + BiometricPolicy, + LocalAuthenticationStrategy, + LocalAuthenticationLevel, +} from 'react-native-auth0'; + +function App() { + return ( + + + + ); +} +``` + +### Using with Auth0 Class + +```js +import Auth0, { + BiometricPolicy, + LocalAuthenticationStrategy, + LocalAuthenticationLevel, +} from 'react-native-auth0'; + +const auth0 = new Auth0({ + domain: 'YOUR_AUTH0_DOMAIN', + clientId: 'YOUR_AUTH0_CLIENT_ID', + localAuthenticationOptions: { + title: 'Authenticate to access credentials', + subtitle: 'Please authenticate to continue', + description: 'We need to authenticate you to retrieve your credentials', + cancelTitle: 'Cancel', + evaluationPolicy: LocalAuthenticationStrategy.deviceOwnerWithBiometrics, + fallbackTitle: 'Use Passcode', + authenticationLevel: LocalAuthenticationLevel.strong, + deviceCredentialFallback: true, + biometricPolicy: BiometricPolicy.session, + biometricTimeout: 300, // 5 minutes + }, +}); + +// Get credentials - will prompt for biometric authentication based on policy +const credentials = await auth0.credentialsManager.getCredentials(); +``` + +### Platform-Specific Behavior + +#### Android + +- `BiometricPolicy.default` and `BiometricPolicy.always` both map to the Android SDK's "Always" policy +- Uses `BiometricPrompt` for authentication +- Session state is stored in memory and cleared on app restart + +#### iOS + +- `BiometricPolicy.default` reuses the same `LAContext`, allowing the system to manage prompt frequency +- `BiometricPolicy.always`, `session`, and `appLifecycle` create a fresh `LAContext` to ensure reliable prompts +- Uses Face ID or Touch ID based on device capabilities +- Session state is thread-safe and managed in memory + +### Migration from Previous Behavior + +If you were not explicitly configuring biometric authentication before, the new `BiometricPolicy.default` maintains backward-compatible behavior. To enforce stricter biometric requirements, switch to `BiometricPolicy.always`. + ## Management API (Users) ### Patch user with user_metadata diff --git a/android/build.gradle b/android/build.gradle index ffcf375f..73bbe866 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.11.0' + implementation 'com.auth0.android:auth0:3.12.0' } if (isNewArchitectureEnabled()) { diff --git a/android/src/main/java/com/auth0/react/LocalAuthenticationOptionsParser.kt b/android/src/main/java/com/auth0/react/LocalAuthenticationOptionsParser.kt index 5c7299fb..d0ca17c7 100644 --- a/android/src/main/java/com/auth0/react/LocalAuthenticationOptionsParser.kt +++ b/android/src/main/java/com/auth0/react/LocalAuthenticationOptionsParser.kt @@ -1,6 +1,7 @@ package com.auth0.react import com.auth0.android.authentication.storage.AuthenticationLevel +import com.auth0.android.authentication.storage.BiometricPolicy import com.auth0.android.authentication.storage.LocalAuthenticationOptions import com.facebook.react.bridge.ReadableMap @@ -11,16 +12,18 @@ object LocalAuthenticationOptionsParser { private const val CANCEL_TITLE_KEY = "cancel" private const val AUTHENTICATION_LEVEL_KEY = "authenticationLevel" private const val DEVICE_CREDENTIAL_FALLBACK_KEY = "deviceCredentialFallback" + private const val BIOMETRIC_POLICY_KEY = "biometricPolicy" + private const val BIOMETRIC_TIMEOUT_KEY = "biometricTimeout" fun fromMap(map: ReadableMap): LocalAuthenticationOptions { val title = map.getString(TITLE_KEY) ?: throw IllegalArgumentException("LocalAuthenticationOptionsParser: fromMap: The 'title' field is required") - + val subtitle = map.getString(SUBTITLE_KEY) val description = map.getString(DESCRIPTION_KEY) val cancelTitle = map.getString(CANCEL_TITLE_KEY) val deviceCredentialFallback = map.getBoolean(DEVICE_CREDENTIAL_FALLBACK_KEY) - + val builder = LocalAuthenticationOptions.Builder() .setTitle(title) .setSubTitle(subtitle) @@ -33,9 +36,22 @@ object LocalAuthenticationOptionsParser { val level = getAuthenticationLevelFromInt(map.getInt(AUTHENTICATION_LEVEL_KEY)) builder.setAuthenticationLevel(level) } - + cancelTitle?.let { builder.setNegativeButtonText(it) } - + + // Parse biometric policy + if (map.hasKey(BIOMETRIC_POLICY_KEY)) { + val policyString = map.getString(BIOMETRIC_POLICY_KEY) + val timeout = if (map.hasKey(BIOMETRIC_TIMEOUT_KEY)) { + map.getInt(BIOMETRIC_TIMEOUT_KEY) + } else { + 3600 // Default 1 hour + } + + val policy = getBiometricPolicyFromString(policyString, timeout) + builder.setPolicy(policy) + } + return builder.build() } @@ -46,5 +62,14 @@ object LocalAuthenticationOptionsParser { else -> AuthenticationLevel.DEVICE_CREDENTIAL } } + + private fun getBiometricPolicyFromString(policy: String?, timeout: Int): BiometricPolicy { + return when (policy) { + "default", "always", null -> BiometricPolicy.Always // Map both 'default' and 'always' to Always + "session" -> BiometricPolicy.Session(timeout) + "appLifecycle" -> BiometricPolicy.AppLifecycle(timeout) + else -> BiometricPolicy.Always // Default to Always + } + } } diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index cec6f8d7..b6533c10 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,6 +1,6 @@ PODS: - A0Auth0 (5.2.1): - - Auth0 (= 2.14) + - Auth0 (= 2.16) - boost - DoubleConversion - fast_float @@ -28,7 +28,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - Auth0 (2.14.0): + - Auth0 (2.16.0): - JWTDecode (= 3.3.0) - SimpleKeychain (= 1.3.0) - boost (1.84.0) @@ -2778,8 +2778,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - A0Auth0: 9253099fae9372f663f89abbf5d02a6b36faf45c - Auth0: 022dda235af8a664a4faf9e7b60b063b5bc08373 + A0Auth0: 936bf2484d314ca9ae7c3285bcc53ebe92b5e1cd + Auth0: 6db7cf0801a5201b3c6c376f07b8dfecfac21ebe boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6 diff --git a/example/src/navigation/HooksDemoNavigator.tsx b/example/src/navigation/HooksDemoNavigator.tsx index 587c8d76..d7205cd4 100644 --- a/example/src/navigation/HooksDemoNavigator.tsx +++ b/example/src/navigation/HooksDemoNavigator.tsx @@ -1,5 +1,11 @@ import React from 'react'; -import { Auth0Provider, useAuth0 } from 'react-native-auth0'; +import { + Auth0Provider, + useAuth0, + BiometricPolicy, + LocalAuthenticationStrategy, + LocalAuthenticationLevel, +} from 'react-native-auth0'; import AuthStackNavigator from './AuthStackNavigator'; import MainTabNavigator from './MainTabNavigator'; import { ActivityIndicator, View, StyleSheet } from 'react-native'; @@ -31,10 +37,45 @@ const AppContent = () => { /** * This component wraps the entire Hooks-based demo flow with the Auth0Provider, * making the authentication context available to all its child screens. + * + * Biometric Policy Examples: + * - BiometricPolicy.default: System-managed, may skip prompt if recently authenticated + * - BiometricPolicy.always: Always shows biometric prompt on every credential access + * - BiometricPolicy.session: Shows prompt once, then caches for specified timeout + * - BiometricPolicy.appLifecycle: Shows prompt once per app lifecycle + * + * Uncomment different policies below to test them. */ const HooksDemoNavigator = () => { return ( - + ); diff --git a/example/src/navigation/MainTabNavigator.tsx b/example/src/navigation/MainTabNavigator.tsx index 0a9c866f..af4632ea 100644 --- a/example/src/navigation/MainTabNavigator.tsx +++ b/example/src/navigation/MainTabNavigator.tsx @@ -3,7 +3,6 @@ import React from 'react'; 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'; @@ -34,7 +33,6 @@ const MainTabNavigator = () => { // You can add icons here if desired /> - ); diff --git a/example/src/navigation/RootNavigator.tsx b/example/src/navigation/RootNavigator.tsx index eb84bbeb..00b53c7e 100644 --- a/example/src/navigation/RootNavigator.tsx +++ b/example/src/navigation/RootNavigator.tsx @@ -1,10 +1,14 @@ // example/src/navigation/RootNavigator.tsx -import React from 'react'; +import React, { Suspense } from 'react'; +import { ActivityIndicator, View, StyleSheet } from 'react-native'; import { createStackNavigator } from '@react-navigation/stack'; import SelectionScreen from '../screens/SelectionScreen'; -import HooksDemoNavigator from './HooksDemoNavigator'; -import ClassDemoNavigator from './ClassDemoNavigator'; + +// Lazy load the demo navigators to prevent Auth0Provider from initializing +// until the user actually navigates to those screens. +const HooksDemoNavigator = React.lazy(() => import('./HooksDemoNavigator')); +const ClassDemoNavigator = React.lazy(() => import('./ClassDemoNavigator')); // Define the parameter list for type safety export type RootStackParamList = { @@ -15,6 +19,13 @@ export type RootStackParamList = { const Stack = createStackNavigator(); +// Loading fallback component +const LoadingFallback = () => ( + + + +); + /** * The top-level navigator that allows the user to select which * demo they want to see: the recommended Hooks-based approach or @@ -33,16 +44,35 @@ const RootNavigator = () => { /> + > + {() => ( + }> + + + )} + + > + {() => ( + }> + + + )} + ); }; +const styles = StyleSheet.create({ + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#FFFFFF', + }, +}); + export default RootNavigator; diff --git a/example/src/screens/hooks/Api.tsx b/example/src/screens/hooks/Api.tsx deleted file mode 100644 index 8f97a5a9..00000000 --- a/example/src/screens/hooks/Api.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React, { useState } from 'react'; -import { SafeAreaView, ScrollView, StyleSheet } from 'react-native'; -import Auth0, { useAuth0, User } from 'react-native-auth0'; -import Button from '../../components/Button'; -import Header from '../../components/Header'; -import Result from '../../components/Result'; -import config from '../../auth0-configuration'; - -const AUTH0_DOMAIN = config.domain; -const AUTH0_CLIENT_ID = config.clientId; - -const auth0 = new Auth0({ domain: AUTH0_DOMAIN, clientId: AUTH0_CLIENT_ID }); - -const ApiScreen = () => { - const { user, getCredentials } = useAuth0(); - const [apiResult, setApiResult] = useState(null); - const [apiError, setApiError] = useState(null); - - const onCallApi = async () => { - try { - const credentials = await getCredentials( - 'openid profile email read:current_user' - ); - if (!credentials || !user?.sub) { - throw new Error('Could not get credentials or user ID.'); - } - - const managementClient = auth0.users(credentials.accessToken); - const fullProfile = await managementClient.getUser({ id: user.sub }); - - setApiResult(fullProfile); - setApiError(null); - } catch (e) { - setApiError(e as Error); - } - }; - - return ( - -
- - -