diff --git a/FAQ.md b/FAQ.md index 9338d133..abb115c7 100644 --- a/FAQ.md +++ b/FAQ.md @@ -18,6 +18,7 @@ 16. [What happens if I disable DPoP after enabling it?](#16-what-happens-if-i-disable-dpop-after-enabling-it) 17. [Why does the app hang or freeze during Social Login (Google, Facebook, etc.)?](#17-why-does-the-app-hang-or-freeze-during-social-login-google-facebook-etc) 18. [How do I refresh the user profile (e.g. `emailVerified`) after it changes on the server?](#18-how-do-i-refresh-the-user-profile-eg-emailverified-after-it-changes-on-the-server) +19. [Why does login fail after the Android system kills my app during the browser step?](#19-why-does-login-fail-after-the-android-system-kills-my-app-during-the-browser-step) ## 1. How can I have separate Auth0 domains for each environment on Android? @@ -1021,3 +1022,70 @@ function VerifyEmailScreen() { | `getCredentials` promise never resolves | Missing refresh token or network issue | Ensure `offline_access` is included during login, and check network connectivity. | > **Note**: This behavior differs from the web SDK (`@auth0/auth0-spa-js`), where token refresh is handled automatically via silent authentication using iframes. On native platforms (iOS/Android), a refresh token is explicitly required. + +## 19. Why does login fail after the Android system kills my app during the browser step? + +### The problem + +On Android, while the user is in the Auth0 login browser (Chrome Custom Tabs), the OS may kill your app's process to reclaim memory. This is common on low-memory devices, and it happens **every time** when the developer option **Settings → Developer options → Don't keep activities** is enabled. + +When the user finishes logging in, the deep link cold-starts your app. The SDK recovers the login automatically and the user ends up logged in — but only if your `MainActivity` is set up to survive the restart. If it is not, the app can crash on restore (`java.lang.IllegalStateException: Screen fragments should never be restored`) before recovery can run, and the user is left logged out. + +### The solution + +If your app uses `react-native-screens` (which React Navigation does by default), your `MainActivity` must discard the saved view state by passing `null` to `super.onCreate`. This is a general requirement of `react-native-screens`, which cannot restore its fragment hierarchy after process death — it is not specific to Auth0. Apps that do not use `react-native-screens` are unaffected. + +This applies to **both bare React Native and Expo** (for Expo, edit the generated file after `expo prebuild`). Edit `android/app/src/main/java/.../MainActivity.kt` (or `.java`) so `onCreate` passes `null`: + +```diff ++ import android.os.Bundle + + class MainActivity : ReactActivity() { + override fun getMainComponentName(): String = "YourApp" + ++ override fun onCreate(savedInstanceState: Bundle?) { ++ super.onCreate(null) ++ } + } +``` + +For Java: + +```diff ++ import android.os.Bundle; + + public class MainActivity extends ReactActivity { ++ @Override ++ protected void onCreate(Bundle savedInstanceState) { ++ super.onCreate(null); ++ } + } +``` + +### How the login continues after the app restarts + +When the process was killed mid-login, the original `authorize()` promise no longer exists — the app has cold-started. The recovered credentials are cached natively and handed back to your app on the next launch through `resumeSession()`. How you consume them depends on which API you use: + +**Hooks API (`Auth0Provider` / `useAuth0`):** Nothing to do. `Auth0Provider` automatically calls `resumeSession()` while it initializes, stores the recovered credentials, and populates `user`. After the restart the user simply appears logged in: + +```jsx +const { user, isLoading } = useAuth0(); +// After process-death recovery, `user` is populated once `isLoading` becomes false — +// exactly as if a normal login had completed. +``` + +**Imperative API (`new Auth0(...)`):** Call `resumeSession()` yourself once on app launch, before deciding whether the user is logged in. It resolves the recovered credentials, or `null` when there is nothing to recover (the normal case on every other launch and on iOS/web): + +```js +const auth0 = new Auth0({ domain, clientId }); + +// On app launch, before routing to your logged-in/logged-out screens: +const recovered = await auth0.webAuth.resumeSession(); +if (recovered) { + // A login that was interrupted by process death just completed. + await auth0.credentialsManager.saveCredentials(recovered); +} +const isLoggedIn = await auth0.credentialsManager.hasValidCredentials(); +``` + +> **Note:** This recovery also relies on a fix in the underlying `Auth0.Android` SDK. Ensure you are on a version that includes it (see the changelog). On iOS and web `resumeSession()` always resolves `null`, since they do not have this class of failure. diff --git a/android/src/main/java/com/auth0/react/A0Auth0Module.kt b/android/src/main/java/com/auth0/react/A0Auth0Module.kt index 186d43bc..2643cf01 100644 --- a/android/src/main/java/com/auth0/react/A0Auth0Module.kt +++ b/android/src/main/java/com/auth0/react/A0Auth0Module.kt @@ -3,6 +3,8 @@ 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 com.auth0.android.Auth0 import com.auth0.android.authentication.AuthenticationAPIClient @@ -38,6 +40,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 companion object { const val NAME = "A0Auth0" + private const val RESUME_SESSION_TIMEOUT_MS = 10_000L private const val CREDENTIAL_MANAGER_ERROR_CODE = "CREDENTIAL_MANAGER_ERROR" private const val BIOMETRICS_AUTHENTICATION_ERROR_CODE = "BIOMETRICS_CONFIGURATION_ERROR" @@ -131,6 +134,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 WebAuthProvider.useDPoP(reactContext) } webAuthPromise = promise + WebAuthRecovery.markFlowInProgress(reactContext) val cleanedParameters = mutableMapOf() additionalParameters?.let { params -> @@ -169,12 +173,14 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 builder.start(reactContext.currentActivity as Activity, object : com.auth0.android.callback.Callback { override fun onSuccess(result: Credentials) { + WebAuthRecovery.clearFlowInProgress(reactContext) val map = CredentialsParser.toMap(result) promise.resolve(map) webAuthPromise = null } override fun onFailure(error: AuthenticationException) { + WebAuthRecovery.clearFlowInProgress(reactContext) handleError(error, promise) webAuthPromise = null } @@ -826,6 +832,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 override fun onNewIntent(intent: Intent) { webAuthPromise?.let { promise -> + WebAuthRecovery.clearFlowInProgress(reactContext) promise.reject( "a0.session.browser_terminated", "The browser window was closed by a new instance of the application" @@ -834,6 +841,66 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 } } + /** + * Drains a Universal Login result recovered after Android process death. + * + * After process death the OS recreates `AuthenticationActivity`, restores the + * `OAuthManager`, and completes the token exchange before the React bridge boots. + * That result is delivered to `WebAuthProvider.addCallback` subscribers, never to the + * `start()` callback in [webAuth], whose JS Promise no longer exists. `WebAuthProvider` + * buffers a recovered result until the first callback is registered, so JS can claim it + * by registering here on launch — even though the bridge boots long after the exchange. + * + * Resolves the recovered credentials, rejects with the recovered error, or resolves + * `null` when there is nothing to recover. When a flow was in progress but its token + * exchange hasn't finished yet, the callback stays registered until the result arrives + * or a timeout elapses (resolving `null`) so JS never hangs. + */ + override fun resumeSession(promise: Promise) { + // A normal launch (no interactive login was pending) has nothing to recover. Resolve + // immediately so JS doesn't wait out the timeout on every cold start. + if (!WebAuthRecovery.isFlowInProgress(reactContext)) { + promise.resolve(null) + return + } + + val settled = java.util.concurrent.atomic.AtomicBoolean(false) + val timeoutHandler = Handler(Looper.getMainLooper()) + + val callback = object : com.auth0.android.callback.Callback { + override fun onSuccess(result: Credentials) { + finish { promise.resolve(CredentialsParser.toMap(result)) } + } + + override fun onFailure(error: AuthenticationException) { + finish { handleError(error, promise) } + } + + private fun finish(resolve: () -> Unit) { + if (!settled.compareAndSet(false, true)) return + timeoutHandler.removeCallbacksAndMessages(null) + WebAuthProvider.removeCallback(this) + WebAuthRecovery.clearFlowInProgress(reactContext) + resolve() + } + } + + // If a result is already buffered in WebAuthProvider, addCallback fires synchronously + // (settling before the line below). Otherwise the callback waits for the in-flight + // restore exchange to complete. + WebAuthProvider.addCallback(callback) + + if (!settled.get()) { + timeoutHandler.postDelayed({ + if (settled.compareAndSet(false, true)) { + WebAuthProvider.removeCallback(callback) + WebAuthRecovery.clearFlowInProgress(reactContext) + promise.resolve(null) + } + }, RESUME_SESSION_TIMEOUT_MS) + } + } + override fun resumeWebAuth(url: String, promise: Promise) { // dummy function implementation, as this is only needed in iOS promise.resolve(true) diff --git a/android/src/main/java/com/auth0/react/WebAuthRecovery.kt b/android/src/main/java/com/auth0/react/WebAuthRecovery.kt new file mode 100644 index 00000000..b83d5362 --- /dev/null +++ b/android/src/main/java/com/auth0/react/WebAuthRecovery.kt @@ -0,0 +1,42 @@ +package com.auth0.react + +import android.content.Context + +/** + * Tracks whether an interactive Universal Login is in flight across a possible Android + * process death, so a later cold start can tell a genuine recovery scenario apart from a + * normal launch. + * + * The recovered credentials themselves are buffered by `WebAuthProvider` (it holds a result + * that completed via the state-restore path until the first `addCallback` subscriber is + * registered), so this SDK only needs the in-progress marker. It is persisted because the + * process may be killed before any result arrives. + */ +internal object WebAuthRecovery { + + private const val PREFS_NAME = "com.auth0.react.webauth_recovery" + private const val KEY_FLOW_IN_PROGRESS = "flow_in_progress" + + /** + * Records that an interactive login was started. Persisted so it survives the process + * death that can occur while the browser is foregrounded. + */ + fun markFlowInProgress(context: Context) { + prefs(context).edit().putBoolean(KEY_FLOW_IN_PROGRESS, true).apply() + } + + /** + * Clears the in-progress marker once a flow reaches any terminal outcome (success, + * failure, or cancellation), so it doesn't masquerade as a pending recovery next launch. + */ + fun clearFlowInProgress(context: Context) { + prefs(context).edit().putBoolean(KEY_FLOW_IN_PROGRESS, false).apply() + } + + fun isFlowInProgress(context: Context): Boolean { + return prefs(context).getBoolean(KEY_FLOW_IN_PROGRESS, false) + } + + private fun prefs(context: Context) = + context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) +} diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 33aa75a3..8a8b8c41 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -111,6 +111,10 @@ dependencies { // The version of react-native is set by the React Native Gradle Plugin implementation("com.facebook.react:react-android") + // Required by PasskeyModule.kt for the Credential Manager passkey APIs. + implementation("androidx.credentials:credentials:1.3.0") + implementation("androidx.credentials:credentials-play-services-auth:1.3.0") + if (hermesEnabled.toBoolean()) { implementation("com.facebook.react:hermes-android") } else { 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..02313fc9 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,12 @@ class MainActivity : ReactActivity() { */ override fun getMainComponentName(): String = "Auth0Example" + // Pass null so react-native-screens re-initializes cleanly after Android process + // death; required for Auth0 login recovery. See FAQ #19. + 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/ios/Auth0Example.xcodeproj/project.pbxproj b/example/ios/Auth0Example.xcodeproj/project.pbxproj index 03dc33ff..6ebb72ac 100644 --- a/example/ios/Auth0Example.xcodeproj/project.pbxproj +++ b/example/ios/Auth0Example.xcodeproj/project.pbxproj @@ -418,6 +418,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.auth0example; PRODUCT_NAME = Auth0Example; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_OBJC_BRIDGING_HEADER = "Auth0Example/Auth0Example-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -446,6 +447,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.auth0example; PRODUCT_NAME = Auth0Example; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_OBJC_BRIDGING_HEADER = "Auth0Example/Auth0Example-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 09f55925..dc3341c4 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - A0Auth0 (5.6.0): + - A0Auth0 (5.7.0): - Auth0 (= 2.21.2) - hermes-engine - RCTRequired @@ -2197,7 +2197,7 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - A0Auth0: 7017e8ccdeda46385612e0b9ab231d81de53d128 + A0Auth0: 607c4269584a26bac8cc81dda180427306352d89 Auth0: e15bc9c1e39a53efc8853d16460b9be409d5346f FBLazyVector: e97c19a5a442429d1988f182a1940fb08df514da hermes-engine: 5a6d36f29e9659a4242ae9acfdaafa16c394a162 diff --git a/ios/A0Auth0.mm b/ios/A0Auth0.mm index cabf15a9..4c6f0483 100644 --- a/ios/A0Auth0.mm +++ b/ios/A0Auth0.mm @@ -42,11 +42,17 @@ - (dispatch_queue_t)methodQueue } RCT_EXPORT_METHOD(cancelWebAuth:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject) { + reject:(RCTPromiseRejectBlock)reject) { [self.nativeBridge cancelWebAuthWithResolve:resolve reject:reject]; } +RCT_EXPORT_METHOD(resumeSession:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) { + [self.nativeBridge resumeSessionWithResolve:resolve reject:reject]; +} + + RCT_EXPORT_METHOD(clearCredentials:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { [self.nativeBridge clearCredentialsWithResolve:resolve reject:reject]; diff --git a/ios/NativeBridge.swift b/ios/NativeBridge.swift index efcc4cae..cdfcf149 100644 --- a/ios/NativeBridge.swift +++ b/ios/NativeBridge.swift @@ -175,6 +175,12 @@ public class NativeBridge: NSObject { @objc public func cancelWebAuth(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { resolve(WebAuthentication.cancel()) } + + // iOS does not lose the in-flight web auth result to process death the way Android can, + // so there is never a session to recover here. Resolve nil to satisfy the shared contract. + @objc public func resumeSession(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { + resolve(nil) + } @objc public func saveCredentials(credentialsDict: [String: Any], resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { diff --git a/src/core/interfaces/IWebAuthProvider.ts b/src/core/interfaces/IWebAuthProvider.ts index 24c17d5f..478725ef 100644 --- a/src/core/interfaces/IWebAuthProvider.ts +++ b/src/core/interfaces/IWebAuthProvider.ts @@ -79,4 +79,19 @@ export interface IWebAuthProvider { * @returns A promise that resolves when the operation is complete. */ cancelWebAuth(): Promise; + + /** + * Recovers a Universal Login result that completed after the OS killed the app + * process mid-login (Android process death). + * + * @remarks + * **Platform specific:** On Android, if the process is killed while the user is in + * the login browser, the original `authorize()` promise is lost. The recovered + * credentials are cached natively; call this on app launch to claim them. Resolves + * `null` when there is nothing to recover. On iOS and web this is a no-op that + * resolves `null`, since they do not have this class of failure. + * + * @returns A promise that resolves with the recovered credentials, or null. + */ + resumeSession(): Promise; } diff --git a/src/hooks/Auth0Provider.tsx b/src/hooks/Auth0Provider.tsx index 313d7c9e..057f0722 100644 --- a/src/hooks/Auth0Provider.tsx +++ b/src/hooks/Auth0Provider.tsx @@ -82,15 +82,32 @@ export const Auth0Provider = ({ await client.webAuth.checkWebSession(); user = await client.webAuth.getWebUser(); } - } else if (await client.credentialsManager.hasValidCredentials()) { + } else { + // Recover a Universal Login result that completed after the OS killed the + // app process mid-login (Android process death). On other platforms this + // resolves null. If credentials are recovered, persist them so the rest of + // the app treats this exactly like a normal login. try { - const credentials = await client.credentialsManager.getCredentials(); - user = credentials - ? Auth0User.fromIdToken(credentials.idToken) - : null; + const recovered = await client.webAuth.resumeSession(); + if (recovered) { + await client.credentialsManager.saveCredentials(recovered); + user = Auth0User.fromIdToken(recovered.idToken); + } } catch (e) { - if ((e as AuthError).code !== 'no_credentials') { - dispatch({ type: 'ERROR', error: e as AuthError }); + dispatch({ type: 'ERROR', error: e as AuthError }); + } + + if (!user && (await client.credentialsManager.hasValidCredentials())) { + try { + const credentials = + await client.credentialsManager.getCredentials(); + user = credentials + ? Auth0User.fromIdToken(credentials.idToken) + : null; + } catch (e) { + if ((e as AuthError).code !== 'no_credentials') { + dispatch({ type: 'ERROR', error: e as AuthError }); + } } } } diff --git a/src/hooks/__tests__/Auth0Provider.spec.tsx b/src/hooks/__tests__/Auth0Provider.spec.tsx index f1dde875..2732ac26 100644 --- a/src/hooks/__tests__/Auth0Provider.spec.tsx +++ b/src/hooks/__tests__/Auth0Provider.spec.tsx @@ -58,6 +58,7 @@ jest.mock('react-native', () => ({ getBundleIdentifier: jest.fn().mockResolvedValue('com.test.app'), cancelWebAuth: jest.fn(), resumeWebAuth: jest.fn(), + resumeSession: jest.fn().mockResolvedValue(null), })), }, })); @@ -93,6 +94,7 @@ const createMockClient = () => { handleRedirectCallback: jest.fn().mockResolvedValue(undefined), checkWebSession: jest.fn().mockResolvedValue(null), getWebUser: jest.fn().mockResolvedValue(null), + resumeSession: jest.fn().mockResolvedValue(null), }, credentialsManager: { hasValidCredentials: jest.fn().mockResolvedValue(false), @@ -285,6 +287,47 @@ describe('Auth0Provider', () => { expect(MockAuth0User.fromIdToken).toHaveBeenCalledWith('a.b.c'); }); + it('should log in by recovering a session after process death', async () => { + // No stored credentials, but a Universal Login result was recovered after + // the process was killed mid-login (Android process death). + mockClientInstance.credentialsManager.hasValidCredentials.mockResolvedValue( + false + ); + mockClientInstance.webAuth.resumeSession.mockResolvedValueOnce({ + idToken: 'recovered.id.token', + accessToken: 'recovered-token', + tokenType: 'Bearer', + expiresAt: Date.now() / 1000 + 3600, + } as any); + + await act(async () => { + render( + + + + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('user-status')).toHaveTextContent( + 'Logged in as: Test User' + ); + }); + // Recovered credentials are persisted so the rest of the app sees a normal login. + expect( + mockClientInstance.credentialsManager.saveCredentials + ).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: 'recovered-token' }) + ); + expect(MockAuth0User.fromIdToken).toHaveBeenCalledWith( + 'recovered.id.token' + ); + // A stored-credentials lookup is unnecessary once recovery succeeds. + expect( + mockClientInstance.credentialsManager.getCredentials + ).not.toHaveBeenCalled(); + }); + // Note: Platform-specific initialization behavior is covered by existing tests // The refactored initialization logic maintains backward compatibility // while improving platform detection and error handling 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..1ad1e7ef 100644 --- a/src/platforms/native/adapters/__tests__/NativeWebAuthProvider.spec.ts +++ b/src/platforms/native/adapters/__tests__/NativeWebAuthProvider.spec.ts @@ -17,6 +17,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 +228,35 @@ describe('NativeWebAuthProvider', () => { expect(mockBridge.cancelWebAuth).toHaveBeenCalledTimes(1); }); }); + + describe('resumeSession', () => { + it('should return recovered credentials from the bridge', async () => { + const credentials = { + accessToken: 'a', + idToken: 'id', + tokenType: 'Bearer', + expiresAt: 1700000000, + }; + mockBridge.resumeSession.mockResolvedValue(credentials as any); + + const result = await provider.resumeSession(); + + expect(mockBridge.resumeSession).toHaveBeenCalledTimes(1); + expect(result).toEqual(credentials); + }); + + it('should return null when there is nothing to recover', async () => { + mockBridge.resumeSession.mockResolvedValue(null); + + const result = await provider.resumeSession(); + + expect(result).toBeNull(); + }); + + it('should wrap bridge errors in a WebAuthError', async () => { + mockBridge.resumeSession.mockRejectedValue(new Error('boom')); + + await expect(provider.resumeSession()).rejects.toThrow(); + }); + }); }); diff --git a/src/platforms/native/bridge/INativeBridge.ts b/src/platforms/native/bridge/INativeBridge.ts index 9661e7da..23e80da4 100644 --- a/src/platforms/native/bridge/INativeBridge.ts +++ b/src/platforms/native/bridge/INativeBridge.ts @@ -80,6 +80,13 @@ export interface INativeBridge { */ cancelWebAuth(): Promise; + /** + * Drains a web authentication result recovered after Android process death. + * Resolves the recovered credentials, or null when there is 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..2794bbe8 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.resumeSession.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..46275492 100644 --- a/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts +++ b/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts @@ -14,6 +14,7 @@ jest.mock('../../../../specs/NativeA0Auth0', () => ({ hasValidCredentials: jest.fn(), clearCredentials: jest.fn(), cancelWebAuth: jest.fn(), + resumeSession: jest.fn(), resumeWebAuth: jest.fn(), getDPoPHeaders: jest.fn(), clearDPoPKey: jest.fn(), @@ -208,6 +209,28 @@ describe('NativeBridgeManager', () => { }); }); + describe('resumeSession', () => { + it('should wrap recovered native credentials in a Credentials model', async () => { + MockedAuth0NativeModule.resumeSession.mockResolvedValueOnce( + nativeSuccessCredentials as any + ); + + const result = await bridge.resumeSession(); + + expect(MockedAuth0NativeModule.resumeSession).toHaveBeenCalledTimes(1); + expect(result).toBeInstanceOf(Credentials); + expect(result?.accessToken).toBe(nativeSuccessCredentials.accessToken); + }); + + it('should return null when native reports nothing to recover', async () => { + MockedAuth0NativeModule.resumeSession.mockResolvedValueOnce(null as any); + + const result = await bridge.resumeSession(); + + expect(result).toBeNull(); + }); + }); + 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..c31db611 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 an Android-only concern. No-op on web. + return Promise.resolve(null); + } } diff --git a/src/specs/NativeA0Auth0.ts b/src/specs/NativeA0Auth0.ts index 8e04a6e1..951c790e 100644 --- a/src/specs/NativeA0Auth0.ts +++ b/src/specs/NativeA0Auth0.ts @@ -117,6 +117,12 @@ export interface Spec extends TurboModule { */ cancelWebAuth(): Promise; + /** + * Drain a web authentication result recovered after Android process death. + * Resolves the recovered credentials, or null when there is nothing to recover. + */ + resumeSession(): Promise; + /** * Get the DPoP headers for a request */ diff --git a/yarn.lock b/yarn.lock index f0009cf4..066ab491 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6845,11 +6845,11 @@ __metadata: linkType: hard "baseline-browser-mapping@npm:^2.8.25, baseline-browser-mapping@npm:^2.9.0": - version: 2.9.8 - resolution: "baseline-browser-mapping@npm:2.9.8" + version: 2.10.35 + resolution: "baseline-browser-mapping@npm:2.10.35" bin: - baseline-browser-mapping: dist/cli.js - checksum: 10c0/73d8e4aa80345833c5c73d43d8fff87dca67883ad3f8edb8ed13cf0da60a5210816fea868b532016d60ac9f8734946cbe5fcce867113a73c9b2f8b0dbfd1e33e + baseline-browser-mapping: dist/cli.cjs + checksum: 10c0/e92c222d626293925e89c3d5b4a9359f985796e28c37d473ad55b33ef7ed5f9b26cfebbc8620df57512b66049f87120403776f2e603b1a686c469c96047db2b0 languageName: node linkType: hard @@ -7237,17 +7237,10 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.30001754": - version: 1.0.30001757 - resolution: "caniuse-lite@npm:1.0.30001757" - checksum: 10c0/3ccb71fa2bf1f8c96ff1bf9b918b08806fed33307e20a3ce3259155fda131eaf96cfcd88d3d309c8fd7f8285cc71d89a3b93648a1c04814da31c301f98508d42 - languageName: node - linkType: hard - -"caniuse-lite@npm:^1.0.30001759": - version: 1.0.30001760 - resolution: "caniuse-lite@npm:1.0.30001760" - checksum: 10c0/cee26dff5c5b15ba073ab230200e43c0d4e88dc3bac0afe0c9ab963df70aaa876c3e513dde42a027f317136bf6e274818d77b073708b74c5807dfad33c029d3c +"caniuse-lite@npm:^1.0.30001754, caniuse-lite@npm:^1.0.30001759": + version: 1.0.30001797 + resolution: "caniuse-lite@npm:1.0.30001797" + checksum: 10c0/8357f6a70432ad1f1dd39ceeabe6fa7597c76ff45cda2994e4aca3e625bdeb3154b4dfd90b984d343883b2fe4e6abf901739f9f11aa2dfd0254c1de3282ccb76 languageName: node linkType: hard