diff --git a/EXAMPLES.md b/EXAMPLES.md index f55ac098..838a6148 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -91,6 +91,9 @@ - [Allowed Browsers (Android)](#allowed-browsers-android) - [Using with Hooks](#using-with-hooks) - [Using with Auth0 Class](#using-with-auth0-class-1) +- [Recovering Login After Process Death (Android)](#recovering-login-after-process-death-android) + - [Using with Hooks](#recovering-login-using-hooks) + - [Using with Auth0 Class](#recovering-login-using-the-auth0-class) - [DPoP (Demonstrating Proof-of-Possession)](#dpop-demonstrating-proof-of-possession) - [Enabling DPoP](#enabling-dpop) - [Making API calls with DPoP](#making-api-calls-with-dpop) @@ -145,7 +148,9 @@ If you already have credentials (e.g. from `webAuth.authorize()` or `credentials import Auth0, { parseIdToken } from 'react-native-auth0'; const auth0 = new Auth0({ domain, clientId }); -const credentials = await auth0.webAuth.authorize({ scope: 'openid profile email' }); +const credentials = await auth0.webAuth.authorize({ + scope: 'openid profile email', +}); const user = parseIdToken(credentials.idToken); // user.sub, user.name, user.email, etc. ``` @@ -1977,6 +1982,60 @@ await auth0.webAuth.authorize( The same `allowedBrowserPackages` option is also accepted by `clearSession` to restrict which browser handles the logout flow. +## Recovering Login After Process Death (Android) + +On Android, the OS can kill your app's process while the user is completing login in the browser — this is common on devices with aggressive memory management (e.g. Samsung One UI, Xiaomi MIUI), especially during MFA when the user switches apps to fetch a code. When the user finishes and the browser redirects back, the app cold-starts and, without recovery, the in-flight login is lost and the user lands back on the login screen. + +`resumeSession()` recovers that login. The underlying native SDK finishes the token exchange after the process restarts and buffers the result; calling `resumeSession()` once on cold start drains it and returns the recovered `Credentials` (or `null` if there was nothing to recover). + +> This is an Android-only concern. On iOS and web `resumeSession()` is a no-op that resolves with `null`, so it is safe to call unconditionally. It requires `react-native-auth0` bundling Auth0.Android 3.19.0+ (included). No native `MainActivity` changes are needed, so it works the same in bare React Native and Expo. + +### Recovering Login Using Hooks + +Call `resumeSession()` once when your app mounts. If it returns credentials, the hook updates the auth state and persists them automatically, so `user` becomes populated. + +```js +import { useEffect } from 'react'; +import { useAuth0 } from 'react-native-auth0'; + +const App = () => { + const { resumeSession } = useAuth0(); + + useEffect(() => { + resumeSession() + .then((credentials) => { + if (credentials) { + // A login interrupted by process death was recovered. + console.log('Recovered session', credentials.accessToken); + } + }) + .catch((error) => { + console.log('Failed to recover session', error); + }); + }, [resumeSession]); + + // ... +}; +``` + +### Recovering Login Using the Auth0 Class + +```js +import Auth0 from 'react-native-auth0'; + +const auth0 = new Auth0({ + domain: 'YOUR_AUTH0_DOMAIN', + clientId: 'YOUR_AUTH0_CLIENT_ID', +}); + +// Call once on cold start, before showing the login screen. +const credentials = await auth0.webAuth.resumeSession(); +if (credentials) { + await auth0.credentialsManager.saveCredentials(credentials); + // The user is logged in — route them into the app. +} +``` + ## DPoP (Demonstrating Proof-of-Possession) [DPoP](https://datatracker.ietf.org/doc/html/rfc9449) (Demonstrating Proof-of-Possession) is an OAuth 2.0 extension that cryptographically binds access and refresh tokens to a client-specific key pair. This prevents token theft and replay attacks by ensuring that even if a token is intercepted, it cannot be used from a different device. diff --git a/FAQ.md b/FAQ.md index 9338d133..717a4ab3 100644 --- a/FAQ.md +++ b/FAQ.md @@ -159,7 +159,24 @@ See these issues for more information: - [possibility to run with launchMode:singleTop?](https://github.com/auth0/react-native-auth0/issues/170) - [Android singleTask launch mode is required for react-native deep links](https://github.com/auth0/react-native-auth0/issues/556) -### The solution +### Recovering the login (recommended) + +If the OS kills your app's process entirely while the browser is open (rather than just pausing the activity), the SDK can still complete the login on restart. Call `resumeSession()` once on cold start to recover it: + +```js +// With the Auth0 class +const credentials = await auth0.webAuth.resumeSession(); + +// Or with hooks +const { resumeSession } = useAuth0(); +useEffect(() => { + resumeSession(); +}, [resumeSession]); +``` + +If a login was interrupted by process death, `resumeSession()` returns the recovered `Credentials`; otherwise it returns `null`. It is Android-only (a no-op resolving `null` on iOS/web) and requires no `MainActivity` changes, so it works in both bare React Native and Expo. See [Recovering Login After Process Death (Android)](EXAMPLES.md#recovering-login-after-process-death-android) for full examples. + +### The solution (for `singleTask` launch mode) If your Android `launchMode` is set to `singleTask` (check your `AndroidManifest.xml`), that's why this is occurring. Unfortunately, this is not addressable by the react-native-auth0 library. diff --git a/android/build.gradle b/android/build.gradle index 35961589..0c162f0e 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.18.0' + implementation 'com.auth0.android:auth0:3.19.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 186d43bc..6680dbfa 100644 --- a/android/src/main/java/com/auth0/react/A0Auth0Module.kt +++ b/android/src/main/java/com/auth0/react/A0Auth0Module.kt @@ -3,7 +3,10 @@ package com.auth0.react import android.app.Activity import android.content.Intent import android.os.Build +import android.os.Handler +import android.os.Looper import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.LifecycleOwner import com.auth0.android.Auth0 import com.auth0.android.authentication.AuthenticationAPIClient import com.auth0.android.authentication.AuthenticationException @@ -38,6 +41,11 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 companion object { const val NAME = "A0Auth0" + + // Grace window (ms) for an in-flight restored token exchange to complete before + // resumeWebAuthSession resolves null. + private const val RESUME_SESSION_GRACE_MS = 5000L + private const val CREDENTIAL_MANAGER_ERROR_CODE = "CREDENTIAL_MANAGER_ERROR" private const val BIOMETRICS_AUTHENTICATION_ERROR_CODE = "BIOMETRICS_CONFIGURATION_ERROR" @@ -166,19 +174,86 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 } builder.withParameters(cleanedParameters) - builder.start(reactContext.currentActivity as Activity, - object : com.auth0.android.callback.Callback { + + val activity = reactContext.currentActivity + if (activity == null) { + promise.reject("a0.activity_not_available", "Current Activity is not available") + webAuthPromise = null + return + } + // start() registers a LifecycleObserver internally (Auth0.Android 3.19.0+ for + // process-death recovery), which must happen on the main thread. + UiThreadUtil.runOnUiThread { + builder.start(activity, + object : com.auth0.android.callback.Callback { + override fun onSuccess(result: Credentials) { + val map = CredentialsParser.toMap(result) + promise.resolve(map) + webAuthPromise = null + } + + override fun onFailure(error: AuthenticationException) { + handleError(error, promise) + webAuthPromise = null + } + }) + } + } + + @ReactMethod + override fun resumeWebAuthSession(promise: Promise) { + val activity = reactContext.currentActivity + if (activity !is LifecycleOwner) { + // No lifecycle owner to register against; nothing to recover. + promise.resolve(null) + return + } + val lifecycleOwner = activity as LifecycleOwner + + UiThreadUtil.runOnUiThread { + val resolved = java.util.concurrent.atomic.AtomicBoolean(false) + // Holds the pending grace-window timeout so a callback that settles the promise + // first can cancel it, releasing the retained promise/activity references instead + // of leaking them until the delay elapses. + val timeoutHandler = Handler(Looper.getMainLooper()) + val loginCallback = object : + com.auth0.android.callback.Callback { override fun onSuccess(result: Credentials) { - val map = CredentialsParser.toMap(result) - promise.resolve(map) - webAuthPromise = null + if (resolved.compareAndSet(false, true)) { + timeoutHandler.removeCallbacksAndMessages(null) + promise.resolve(CredentialsParser.toMap(result)) + } } override fun onFailure(error: AuthenticationException) { - handleError(error, promise) - webAuthPromise = null + if (resolved.compareAndSet(false, true)) { + timeoutHandler.removeCallbacksAndMessages(null) + handleError(error, promise) + } } - }) + } + // Logout recovery is out of scope; provide a no-op logout callback. + val logoutCallback = object : + com.auth0.android.callback.Callback { + override fun onSuccess(result: Void?) {} + override fun onFailure(error: AuthenticationException) {} + } + + // Registering against an already-RESUMED owner synchronously replays onResume, + // draining any result buffered after process-death recovery. + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback, logoutCallback) + + // Safety net for the rare case where the restored token exchange is still in + // flight: give it a short grace window, then resolve null if nothing arrived. + // The login callback cancels this timeout if it settles first. + if (!resolved.get()) { + timeoutHandler.postDelayed({ + if (resolved.compareAndSet(false, true)) { + promise.resolve(null) + } + }, RESUME_SESSION_GRACE_MS) + } + } } @ReactMethod @@ -392,16 +467,25 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 ) } - builder.start(reactContext.currentActivity as FragmentActivity, - object : com.auth0.android.callback.Callback { - override fun onSuccess(result: Void?) { - promise.resolve(true) - } + val activity = reactContext.currentActivity + if (activity !is FragmentActivity) { + promise.reject("a0.activity_not_available", "Current Activity is not a FragmentActivity") + return + } + // start() registers a LifecycleObserver internally (Auth0.Android 3.19.0+), + // which must happen on the main thread. + UiThreadUtil.runOnUiThread { + builder.start(activity, + object : com.auth0.android.callback.Callback { + override fun onSuccess(result: Void?) { + promise.resolve(true) + } - override fun onFailure(e: AuthenticationException) { - handleError(e, promise) - } - }) + override fun onFailure(e: AuthenticationException) { + handleError(e, promise) + } + }) + } } @ReactMethod diff --git a/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt b/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt index 21434199..60f3bf38 100644 --- a/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt +++ b/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt @@ -96,6 +96,10 @@ abstract class A0Auth0Spec(context: ReactApplicationContext) : ReactContextBaseJ @DoNotStrip abstract fun resumeWebAuth(url: String, promise: Promise) + @ReactMethod + @DoNotStrip + abstract fun resumeWebAuthSession(promise: Promise) + @ReactMethod @DoNotStrip abstract fun cancelWebAuth(promise: Promise) diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 33aa75a3..68e8709f 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -116,4 +116,8 @@ dependencies { } else { implementation jscFlavor } + + // Required by PasskeyModule.kt (androidx.credentials.*) + implementation("androidx.credentials:credentials:1.3.0") + implementation("androidx.credentials:credentials-play-services-auth:1.3.0") } diff --git a/example/android/app/src/main/java/com/auth0example/MainActivity.kt b/example/android/app/src/main/java/com/auth0example/MainActivity.kt index 67b0d6c7..5df48754 100644 --- a/example/android/app/src/main/java/com/auth0example/MainActivity.kt +++ b/example/android/app/src/main/java/com/auth0example/MainActivity.kt @@ -1,5 +1,6 @@ package com.auth0example +import android.os.Bundle import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled @@ -13,6 +14,17 @@ class MainActivity : ReactActivity() { */ override fun getMainComponentName(): String = "Auth0Example" + /** + * react-native-screens requires that fragment state is never restored, otherwise the app + * crashes with "Screen fragments should never be restored" when the OS recreates the + * Activity (e.g. after the Auth0 redirect or process death). Passing null discards the + * saved fragment hierarchy so React Native rebuilds it fresh. + * See https://github.com/software-mansion/react-native-screens/issues/17 + */ + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(null) + } + /** * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] diff --git a/example/src/navigation/HooksDemoNavigator.tsx b/example/src/navigation/HooksDemoNavigator.tsx index d7205cd4..b7e0d73c 100644 --- a/example/src/navigation/HooksDemoNavigator.tsx +++ b/example/src/navigation/HooksDemoNavigator.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { Auth0Provider, useAuth0, @@ -8,7 +8,7 @@ import { } from 'react-native-auth0'; import AuthStackNavigator from './AuthStackNavigator'; import MainTabNavigator from './MainTabNavigator'; -import { ActivityIndicator, View, StyleSheet } from 'react-native'; +import { ActivityIndicator, View, StyleSheet, Platform } from 'react-native'; import config from '../auth0-configuration'; const AUTH0_DOMAIN = config.domain; @@ -20,7 +20,26 @@ const AUTH0_CLIENT_ID = config.clientId; * It's rendered inside the Auth0Provider so it can use the useAuth0 hook. */ const AppContent = () => { - const { user, isLoading } = useAuth0(); + const { user, isLoading, resumeSession } = useAuth0(); + + // On Android the OS can kill the app process while the user is completing + // login in the browser. When the app cold-starts, resumeSession() recovers + // any login that finished after the process was killed. It is a safe no-op + // that resolves null on iOS and web, so it can be called unconditionally. + useEffect(() => { + if (Platform.OS !== 'android') { + return; + } + resumeSession() + .then((credentials) => { + if (credentials) { + console.log('Recovered login after process death'); + } + }) + .catch((e) => { + console.warn('resumeSession failed', e); + }); + }, [resumeSession]); if (isLoading) { return ( diff --git a/example/src/screens/hooks/Home.tsx b/example/src/screens/hooks/Home.tsx index 0ccd2a2d..ff47bbdf 100644 --- a/example/src/screens/hooks/Home.tsx +++ b/example/src/screens/hooks/Home.tsx @@ -29,6 +29,7 @@ import { const HomeScreen = () => { const { authorize, + resumeSession, loginWithPasswordRealm, sendEmailCode, authorizeWithEmail, @@ -71,6 +72,19 @@ const HomeScreen = () => { } }; + const onResumeSession = async () => { + try { + const credentials = await resumeSession(); + if (credentials) { + Alert.alert('Recovered', 'Login was recovered after process death.'); + } else { + Alert.alert('Nothing to recover', 'No pending login was found.'); + } + } catch (e) { + setApiError(e as Error); + } + }; + const onLoginWithPassword = async () => { try { await loginWithPasswordRealm({ @@ -272,6 +286,15 @@ const HomeScreen = () => {
diff --git a/ios/A0Auth0.mm b/ios/A0Auth0.mm index cabf15a9..aeb4084f 100644 --- a/ios/A0Auth0.mm +++ b/ios/A0Auth0.mm @@ -108,11 +108,19 @@ - (dispatch_queue_t)methodQueue RCT_EXPORT_METHOD(resumeWebAuth:(NSString *)url resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject) { + reject:(RCTPromiseRejectBlock)reject) { [self.nativeBridge resumeWebAuthWithUrl:url resolve:resolve reject:reject]; } +RCT_EXPORT_METHOD(resumeWebAuthSession:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) { + // Android-only process-death recovery. iOS uses ASWebAuthenticationSession, + // which has no equivalent failure mode, so this resolves null. + resolve([NSNull null]); +} + + RCT_EXPORT_METHOD(saveCredentials:(NSDictionary *)credentials resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { diff --git a/src/core/interfaces/IWebAuthProvider.ts b/src/core/interfaces/IWebAuthProvider.ts index 24c17d5f..dbd12f48 100644 --- a/src/core/interfaces/IWebAuthProvider.ts +++ b/src/core/interfaces/IWebAuthProvider.ts @@ -79,4 +79,21 @@ export interface IWebAuthProvider { * @returns A promise that resolves when the operation is complete. */ cancelWebAuth(): Promise; + + /** + * Recovers a login that completed while the app process was killed. + * + * @remarks + * **Platform specific:** This addresses Android process death — when the OS kills the + * app while the user is completing login in the browser (common on devices with + * aggressive memory management), the native SDK can still finish the token exchange on + * restart. Call this once on cold start to drain that recovered result. + * + * On iOS and web this is a no-op that resolves with `null`, so it is safe to call + * unconditionally regardless of platform. + * + * @returns A promise that resolves with the recovered {@link Credentials}, or `null` if + * there was nothing to recover. Rejects if the recovered authentication failed. + */ + resumeSession(): Promise; } diff --git a/src/hooks/Auth0Context.ts b/src/hooks/Auth0Context.ts index a550a163..03418fa3 100644 --- a/src/hooks/Auth0Context.ts +++ b/src/hooks/Auth0Context.ts @@ -137,6 +137,21 @@ export interface Auth0ContextInterface extends AuthState { */ cancelWebAuth: () => Promise; + /** + * Recovers a login that completed after Android process death. + * + * @remarks + * On Android, the OS may kill the app while the user completes login in the browser. + * The native SDK can still finish the token exchange on restart; call this once on + * cold start to drain that recovered result. On success the user is logged in and the + * credentials are persisted. On iOS and web this resolves with `null`, so it is safe to + * call unconditionally. + * + * @returns A promise that resolves with the recovered credentials, or `null` if there + * was nothing to recover. + */ + resumeSession: () => Promise; + /** * Authenticates a user with their username and password. * @remarks This method is not supported on the web platform. @@ -455,6 +470,7 @@ const initialContext: Auth0ContextInterface = { clearApiCredentials: stub, loginWithPasswordRealm: stub, cancelWebAuth: stub, + resumeSession: stub, authorizeWithExchange: stub, createUser: stub, authorizeWithRecoveryCode: stub, diff --git a/src/hooks/Auth0Provider.tsx b/src/hooks/Auth0Provider.tsx index 313d7c9e..bd514b17 100644 --- a/src/hooks/Auth0Provider.tsx +++ b/src/hooks/Auth0Provider.tsx @@ -245,6 +245,23 @@ export const Auth0Provider = ({ [client, voidFlow] ); + const resumeSession = useCallback(async (): Promise => { + try { + const credentials = await client.webAuth.resumeSession(); + if (!credentials) { + return null; + } + const user = Auth0User.fromIdToken(credentials.idToken); + await client.credentialsManager.saveCredentials(credentials); + dispatch({ type: 'LOGIN_COMPLETE', user }); + return credentials; + } catch (e) { + const error = e as AuthError; + dispatch({ type: 'ERROR', error }); + throw error; + } + }, [client]); + const getApiCredentials = useCallback( async ( audience: string, @@ -447,6 +464,7 @@ export const Auth0Provider = ({ getApiCredentials, clearApiCredentials, cancelWebAuth, + resumeSession, loginWithPasswordRealm, createUser, resetPassword, @@ -481,6 +499,7 @@ export const Auth0Provider = ({ getApiCredentials, clearApiCredentials, cancelWebAuth, + resumeSession, loginWithPasswordRealm, createUser, resetPassword, diff --git a/src/hooks/__tests__/Auth0Provider.spec.tsx b/src/hooks/__tests__/Auth0Provider.spec.tsx index f1dde875..140f5757 100644 --- a/src/hooks/__tests__/Auth0Provider.spec.tsx +++ b/src/hooks/__tests__/Auth0Provider.spec.tsx @@ -90,6 +90,7 @@ const createMockClient = () => { authorize: jest.fn().mockResolvedValue(mockCredentials), clearSession: jest.fn().mockResolvedValue(undefined), cancelWebAuth: jest.fn().mockResolvedValue(undefined), + resumeSession: jest.fn().mockResolvedValue(null), handleRedirectCallback: jest.fn().mockResolvedValue(undefined), checkWebSession: jest.fn().mockResolvedValue(null), getWebUser: jest.fn().mockResolvedValue(null), diff --git a/src/platforms/native/adapters/NativeWebAuthProvider.ts b/src/platforms/native/adapters/NativeWebAuthProvider.ts index 3b5bdaba..0503c3cf 100644 --- a/src/platforms/native/adapters/NativeWebAuthProvider.ts +++ b/src/platforms/native/adapters/NativeWebAuthProvider.ts @@ -133,6 +133,14 @@ export class NativeWebAuthProvider implements IWebAuthProvider { } } + async resumeSession(): Promise { + try { + return await this.bridge.resumeSession(); + } catch (error) { + throw new WebAuthError(error as AuthError); + } + } + private async getDefaultScheme(useLegacy: boolean = false): Promise { const bundleId = (await this.bridge.getBundleIdentifier()).toLowerCase(); return useLegacy ? bundleId : `${bundleId}.auth0`; diff --git a/src/platforms/native/adapters/__tests__/NativeWebAuthProvider.spec.ts b/src/platforms/native/adapters/__tests__/NativeWebAuthProvider.spec.ts index 57207fec..90103e72 100644 --- a/src/platforms/native/adapters/__tests__/NativeWebAuthProvider.spec.ts +++ b/src/platforms/native/adapters/__tests__/NativeWebAuthProvider.spec.ts @@ -2,6 +2,7 @@ import { Linking, Platform } from 'react-native'; import { NativeWebAuthProvider } from '../NativeWebAuthProvider'; import { INativeBridge } from '../../bridge'; import { finalizeScope } from '../../../../core/utils'; +import { AuthError, WebAuthError } from '../../../../core/models'; // 1. Mock the dependencies jest.mock('react-native', () => ({ @@ -17,6 +18,7 @@ const mockBridge: jest.Mocked = { authorize: jest.fn(), clearSession: jest.fn(), cancelWebAuth: jest.fn(), + resumeSession: jest.fn(), getBundleIdentifier: jest.fn().mockResolvedValue('com.my-app'), resumeWebAuth: jest.fn(), // Add stubs for other bridge methods @@ -227,4 +229,34 @@ describe('NativeWebAuthProvider', () => { expect(mockBridge.cancelWebAuth).toHaveBeenCalledTimes(1); }); }); + + describe('resumeSession', () => { + it('should return recovered credentials from the bridge', async () => { + const credentials = { accessToken: 'recovered' } as any; + mockBridge.resumeSession.mockResolvedValueOnce(credentials); + + const result = await provider.resumeSession(); + + expect(mockBridge.resumeSession).toHaveBeenCalledTimes(1); + expect(result).toBe(credentials); + }); + + it('should return null when there is nothing to recover', async () => { + mockBridge.resumeSession.mockResolvedValueOnce(null); + + const result = await provider.resumeSession(); + + expect(result).toBeNull(); + }); + + it('should wrap a bridge error in a WebAuthError', async () => { + mockBridge.resumeSession.mockRejectedValueOnce( + new AuthError('a0.session.failed', 'boom') + ); + + await expect(provider.resumeSession()).rejects.toBeInstanceOf( + WebAuthError + ); + }); + }); }); diff --git a/src/platforms/native/bridge/INativeBridge.ts b/src/platforms/native/bridge/INativeBridge.ts index 9661e7da..b304b4fd 100644 --- a/src/platforms/native/bridge/INativeBridge.ts +++ b/src/platforms/native/bridge/INativeBridge.ts @@ -80,6 +80,13 @@ export interface INativeBridge { */ cancelWebAuth(): Promise; + /** + * Recovers a login result that completed after Android process death. + * Resolves with the recovered credentials, or `null` if there was nothing to recover. + * @platform android + */ + resumeSession(): Promise; + /** * Saves credentials to the native secure storage (Keychain/EncryptedSharedPreferences). * @param credentials The credentials to save. diff --git a/src/platforms/native/bridge/NativeBridgeManager.ts b/src/platforms/native/bridge/NativeBridgeManager.ts index d10a4c77..d8f66c41 100644 --- a/src/platforms/native/bridge/NativeBridgeManager.ts +++ b/src/platforms/native/bridge/NativeBridgeManager.ts @@ -130,6 +130,13 @@ export class NativeBridgeManager implements INativeBridge { ); } + async resumeSession(): Promise { + const credential = await this.a0_call( + Auth0NativeModule.resumeWebAuthSession.bind(Auth0NativeModule) + ); + return credential ? new CredentialsModel(credential) : null; + } + async saveCredentials(credentials: Credentials): Promise { return this.a0_call( Auth0NativeModule.saveCredentials.bind(Auth0NativeModule), diff --git a/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts b/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts index 3f96a4f0..b75316db 100644 --- a/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts +++ b/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts @@ -15,6 +15,7 @@ jest.mock('../../../../specs/NativeA0Auth0', () => ({ clearCredentials: jest.fn(), cancelWebAuth: jest.fn(), resumeWebAuth: jest.fn(), + resumeWebAuthSession: jest.fn(), getDPoPHeaders: jest.fn(), clearDPoPKey: jest.fn(), getSSOCredentials: jest.fn(), @@ -208,6 +209,41 @@ describe('NativeBridgeManager', () => { }); }); + describe('resumeSession', () => { + it('should wrap recovered credentials in a Credentials model', async () => { + MockedAuth0NativeModule.resumeWebAuthSession.mockResolvedValueOnce( + nativeSuccessCredentials as any + ); + + const result = await bridge.resumeSession(); + + expect( + MockedAuth0NativeModule.resumeWebAuthSession + ).toHaveBeenCalledTimes(1); + expect(result).toBeInstanceOf(Credentials); + expect(result?.accessToken).toBe(nativeSuccessCredentials.accessToken); + }); + + it('should resolve null when there is nothing to recover', async () => { + MockedAuth0NativeModule.resumeWebAuthSession.mockResolvedValueOnce( + null as any + ); + + const result = await bridge.resumeSession(); + + expect(result).toBeNull(); + }); + + it('should wrap a native error in an AuthError', async () => { + const nativeError = { code: 'a0.session.failed', message: 'boom' }; + MockedAuth0NativeModule.resumeWebAuthSession.mockRejectedValueOnce( + nativeError + ); + + await expect(bridge.resumeSession()).rejects.toBeInstanceOf(AuthError); + }); + }); + describe('getCredentials', () => { it('should call the native getCredentials with all parameters', async () => { const scope = 'openid profile'; diff --git a/src/platforms/web/adapters/WebWebAuthProvider.ts b/src/platforms/web/adapters/WebWebAuthProvider.ts index 4082d991..fd9bf00b 100644 --- a/src/platforms/web/adapters/WebWebAuthProvider.ts +++ b/src/platforms/web/adapters/WebWebAuthProvider.ts @@ -126,4 +126,9 @@ export class WebWebAuthProvider implements IWebAuthProvider { // Web-based flows cannot be programmatically cancelled. This is a no-op. return Promise.resolve(); } + + async resumeSession(): Promise { + // Process-death recovery is Android-only. No-op on web. + return Promise.resolve(null); + } } diff --git a/src/platforms/web/adapters/__tests__/WebWebAuthProvider.spec.ts b/src/platforms/web/adapters/__tests__/WebWebAuthProvider.spec.ts index 524f9cd3..154e4b3e 100644 --- a/src/platforms/web/adapters/__tests__/WebWebAuthProvider.spec.ts +++ b/src/platforms/web/adapters/__tests__/WebWebAuthProvider.spec.ts @@ -259,4 +259,10 @@ describe('WebWebAuthProvider', () => { expect(mockSpaClient.logout).not.toHaveBeenCalled(); }); }); + + describe('resumeSession', () => { + it('should resolve null as it is a no-op on the web', async () => { + await expect(provider.resumeSession()).resolves.toBeNull(); + }); + }); }); diff --git a/src/specs/NativeA0Auth0.ts b/src/specs/NativeA0Auth0.ts index 8e04a6e1..0696369f 100644 --- a/src/specs/NativeA0Auth0.ts +++ b/src/specs/NativeA0Auth0.ts @@ -112,6 +112,13 @@ export interface Spec extends TurboModule { */ resumeWebAuth(url: string): Promise; + /** + * Recover a web authentication result after Android process death. + * Resolves with Credentials if a post-process-death login was recovered, + * or null if there was nothing to recover. No-op (resolves null) on iOS. + */ + resumeWebAuthSession(): Promise; + /** * Cancel web authentication */