Skip to content

Commit f8cc090

Browse files
fix(android): recover Universal Login after process death via resumeSession
1 parent 6fa458d commit f8cc090

20 files changed

Lines changed: 383 additions & 25 deletions

File tree

FAQ.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
16. [What happens if I disable DPoP after enabling it?](#16-what-happens-if-i-disable-dpop-after-enabling-it)
1919
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)
2020
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)
21+
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)
2122

2223
## 1. How can I have separate Auth0 domains for each environment on Android?
2324

@@ -1021,3 +1022,70 @@ function VerifyEmailScreen() {
10211022
| `getCredentials` promise never resolves | Missing refresh token or network issue | Ensure `offline_access` is included during login, and check network connectivity. |
10221023

10231024
> **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.
1025+
1026+
## 19. Why does login fail after the Android system kills my app during the browser step?
1027+
1028+
### The problem
1029+
1030+
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.
1031+
1032+
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.
1033+
1034+
### The solution
1035+
1036+
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.
1037+
1038+
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`:
1039+
1040+
```diff
1041+
+ import android.os.Bundle
1042+
1043+
class MainActivity : ReactActivity() {
1044+
override fun getMainComponentName(): String = "YourApp"
1045+
1046+
+ override fun onCreate(savedInstanceState: Bundle?) {
1047+
+ super.onCreate(null)
1048+
+ }
1049+
}
1050+
```
1051+
1052+
For Java:
1053+
1054+
```diff
1055+
+ import android.os.Bundle;
1056+
1057+
public class MainActivity extends ReactActivity {
1058+
+ @Override
1059+
+ protected void onCreate(Bundle savedInstanceState) {
1060+
+ super.onCreate(null);
1061+
+ }
1062+
}
1063+
```
1064+
1065+
### How the login continues after the app restarts
1066+
1067+
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:
1068+
1069+
**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:
1070+
1071+
```jsx
1072+
const { user, isLoading } = useAuth0();
1073+
// After process-death recovery, `user` is populated once `isLoading` becomes false —
1074+
// exactly as if a normal login had completed.
1075+
```
1076+
1077+
**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):
1078+
1079+
```js
1080+
const auth0 = new Auth0({ domain, clientId });
1081+
1082+
// On app launch, before routing to your logged-in/logged-out screens:
1083+
const recovered = await auth0.webAuth.resumeSession();
1084+
if (recovered) {
1085+
// A login that was interrupted by process death just completed.
1086+
await auth0.credentialsManager.saveCredentials(recovered);
1087+
}
1088+
const isLoggedIn = await auth0.credentialsManager.hasValidCredentials();
1089+
```
1090+
1091+
> **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.

android/src/main/java/com/auth0/react/A0Auth0Module.kt

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package com.auth0.react
33
import android.app.Activity
44
import android.content.Intent
55
import android.os.Build
6+
import android.os.Handler
7+
import android.os.Looper
68
import androidx.fragment.app.FragmentActivity
79
import com.auth0.android.Auth0
810
import com.auth0.android.authentication.AuthenticationAPIClient
@@ -38,6 +40,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
3840

3941
companion object {
4042
const val NAME = "A0Auth0"
43+
private const val RESUME_SESSION_TIMEOUT_MS = 10_000L
4144
private const val CREDENTIAL_MANAGER_ERROR_CODE = "CREDENTIAL_MANAGER_ERROR"
4245
private const val BIOMETRICS_AUTHENTICATION_ERROR_CODE = "BIOMETRICS_CONFIGURATION_ERROR"
4346

@@ -131,6 +134,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
131134
WebAuthProvider.useDPoP(reactContext)
132135
}
133136
webAuthPromise = promise
137+
WebAuthRecovery.markFlowInProgress(reactContext)
134138
val cleanedParameters = mutableMapOf<String, String>()
135139

136140
additionalParameters?.let { params ->
@@ -169,12 +173,14 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
169173
builder.start(reactContext.currentActivity as Activity,
170174
object : com.auth0.android.callback.Callback<Credentials, AuthenticationException> {
171175
override fun onSuccess(result: Credentials) {
176+
WebAuthRecovery.clearFlowInProgress(reactContext)
172177
val map = CredentialsParser.toMap(result)
173178
promise.resolve(map)
174179
webAuthPromise = null
175180
}
176181

177182
override fun onFailure(error: AuthenticationException) {
183+
WebAuthRecovery.clearFlowInProgress(reactContext)
178184
handleError(error, promise)
179185
webAuthPromise = null
180186
}
@@ -826,6 +832,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
826832

827833
override fun onNewIntent(intent: Intent) {
828834
webAuthPromise?.let { promise ->
835+
WebAuthRecovery.clearFlowInProgress(reactContext)
829836
promise.reject(
830837
"a0.session.browser_terminated",
831838
"The browser window was closed by a new instance of the application"
@@ -834,6 +841,66 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
834841
}
835842
}
836843

844+
/**
845+
* Drains a Universal Login result recovered after Android process death.
846+
*
847+
* After process death the OS recreates `AuthenticationActivity`, restores the
848+
* `OAuthManager`, and completes the token exchange before the React bridge boots.
849+
* That result is delivered to `WebAuthProvider.addCallback` subscribers, never to the
850+
* `start()` callback in [webAuth], whose JS Promise no longer exists. `WebAuthProvider`
851+
* buffers a recovered result until the first callback is registered, so JS can claim it
852+
* by registering here on launch — even though the bridge boots long after the exchange.
853+
*
854+
* Resolves the recovered credentials, rejects with the recovered error, or resolves
855+
* `null` when there is nothing to recover. When a flow was in progress but its token
856+
* exchange hasn't finished yet, the callback stays registered until the result arrives
857+
* or a timeout elapses (resolving `null`) so JS never hangs.
858+
*/
859+
override fun resumeSession(promise: Promise) {
860+
// A normal launch (no interactive login was pending) has nothing to recover. Resolve
861+
// immediately so JS doesn't wait out the timeout on every cold start.
862+
if (!WebAuthRecovery.isFlowInProgress(reactContext)) {
863+
promise.resolve(null)
864+
return
865+
}
866+
867+
val settled = java.util.concurrent.atomic.AtomicBoolean(false)
868+
val timeoutHandler = Handler(Looper.getMainLooper())
869+
870+
val callback = object : com.auth0.android.callback.Callback<Credentials, AuthenticationException> {
871+
override fun onSuccess(result: Credentials) {
872+
finish { promise.resolve(CredentialsParser.toMap(result)) }
873+
}
874+
875+
override fun onFailure(error: AuthenticationException) {
876+
finish { handleError(error, promise) }
877+
}
878+
879+
private fun finish(resolve: () -> Unit) {
880+
if (!settled.compareAndSet(false, true)) return
881+
timeoutHandler.removeCallbacksAndMessages(null)
882+
WebAuthProvider.removeCallback(this)
883+
WebAuthRecovery.clearFlowInProgress(reactContext)
884+
resolve()
885+
}
886+
}
887+
888+
// If a result is already buffered in WebAuthProvider, addCallback fires synchronously
889+
// (settling before the line below). Otherwise the callback waits for the in-flight
890+
// restore exchange to complete.
891+
WebAuthProvider.addCallback(callback)
892+
893+
if (!settled.get()) {
894+
timeoutHandler.postDelayed({
895+
if (settled.compareAndSet(false, true)) {
896+
WebAuthProvider.removeCallback(callback)
897+
WebAuthRecovery.clearFlowInProgress(reactContext)
898+
promise.resolve(null)
899+
}
900+
}, RESUME_SESSION_TIMEOUT_MS)
901+
}
902+
}
903+
837904
override fun resumeWebAuth(url: String, promise: Promise) {
838905
// dummy function implementation, as this is only needed in iOS
839906
promise.resolve(true)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.auth0.react
2+
3+
import android.content.Context
4+
5+
/**
6+
* Tracks whether an interactive Universal Login is in flight across a possible Android
7+
* process death, so a later cold start can tell a genuine recovery scenario apart from a
8+
* normal launch.
9+
*
10+
* The recovered credentials themselves are buffered by `WebAuthProvider` (it holds a result
11+
* that completed via the state-restore path until the first `addCallback` subscriber is
12+
* registered), so this SDK only needs the in-progress marker. It is persisted because the
13+
* process may be killed before any result arrives.
14+
*/
15+
internal object WebAuthRecovery {
16+
17+
private const val PREFS_NAME = "com.auth0.react.webauth_recovery"
18+
private const val KEY_FLOW_IN_PROGRESS = "flow_in_progress"
19+
20+
/**
21+
* Records that an interactive login was started. Persisted so it survives the process
22+
* death that can occur while the browser is foregrounded.
23+
*/
24+
fun markFlowInProgress(context: Context) {
25+
prefs(context).edit().putBoolean(KEY_FLOW_IN_PROGRESS, true).apply()
26+
}
27+
28+
/**
29+
* Clears the in-progress marker once a flow reaches any terminal outcome (success,
30+
* failure, or cancellation), so it doesn't masquerade as a pending recovery next launch.
31+
*/
32+
fun clearFlowInProgress(context: Context) {
33+
prefs(context).edit().putBoolean(KEY_FLOW_IN_PROGRESS, false).apply()
34+
}
35+
36+
fun isFlowInProgress(context: Context): Boolean {
37+
return prefs(context).getBoolean(KEY_FLOW_IN_PROGRESS, false)
38+
}
39+
40+
private fun prefs(context: Context) =
41+
context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
42+
}

example/android/app/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ dependencies {
111111
// The version of react-native is set by the React Native Gradle Plugin
112112
implementation("com.facebook.react:react-android")
113113

114+
// Required by PasskeyModule.kt for the Credential Manager passkey APIs.
115+
implementation("androidx.credentials:credentials:1.3.0")
116+
implementation("androidx.credentials:credentials-play-services-auth:1.3.0")
117+
114118
if (hermesEnabled.toBoolean()) {
115119
implementation("com.facebook.react:hermes-android")
116120
} else {

example/android/app/src/main/java/com/auth0example/MainActivity.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.auth0example
22

3+
import android.os.Bundle
34
import com.facebook.react.ReactActivity
45
import com.facebook.react.ReactActivityDelegate
56
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
@@ -13,6 +14,12 @@ class MainActivity : ReactActivity() {
1314
*/
1415
override fun getMainComponentName(): String = "Auth0Example"
1516

17+
// Pass null so react-native-screens re-initializes cleanly after Android process
18+
// death; required for Auth0 login recovery. See FAQ #19.
19+
override fun onCreate(savedInstanceState: Bundle?) {
20+
super.onCreate(null)
21+
}
22+
1623
/**
1724
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
1825
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]

example/ios/Auth0Example.xcodeproj/project.pbxproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,7 @@
418418
PRODUCT_BUNDLE_IDENTIFIER = com.auth0example;
419419
PRODUCT_NAME = Auth0Example;
420420
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
421+
SWIFT_OBJC_BRIDGING_HEADER = "Auth0Example/Auth0Example-Bridging-Header.h";
421422
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
422423
SWIFT_VERSION = 5.0;
423424
TARGETED_DEVICE_FAMILY = "1,2";
@@ -446,6 +447,7 @@
446447
PRODUCT_BUNDLE_IDENTIFIER = com.auth0example;
447448
PRODUCT_NAME = Auth0Example;
448449
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
450+
SWIFT_OBJC_BRIDGING_HEADER = "Auth0Example/Auth0Example-Bridging-Header.h";
449451
SWIFT_VERSION = 5.0;
450452
TARGETED_DEVICE_FAMILY = "1,2";
451453
VERSIONING_SYSTEM = "apple-generic";

example/ios/Podfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
PODS:
2-
- A0Auth0 (5.6.0):
2+
- A0Auth0 (5.7.0):
33
- Auth0 (= 2.21.2)
44
- hermes-engine
55
- RCTRequired
@@ -2197,7 +2197,7 @@ EXTERNAL SOURCES:
21972197
:path: "../node_modules/react-native/ReactCommon/yoga"
21982198

21992199
SPEC CHECKSUMS:
2200-
A0Auth0: 7017e8ccdeda46385612e0b9ab231d81de53d128
2200+
A0Auth0: 607c4269584a26bac8cc81dda180427306352d89
22012201
Auth0: e15bc9c1e39a53efc8853d16460b9be409d5346f
22022202
FBLazyVector: e97c19a5a442429d1988f182a1940fb08df514da
22032203
hermes-engine: 5a6d36f29e9659a4242ae9acfdaafa16c394a162

ios/A0Auth0.mm

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,17 @@ - (dispatch_queue_t)methodQueue
4242
}
4343

4444
RCT_EXPORT_METHOD(cancelWebAuth:(RCTPromiseResolveBlock)resolve
45-
reject:(RCTPromiseRejectBlock)reject) {
45+
reject:(RCTPromiseRejectBlock)reject) {
4646
[self.nativeBridge cancelWebAuthWithResolve:resolve reject:reject];
4747
}
4848

4949

50+
RCT_EXPORT_METHOD(resumeSession:(RCTPromiseResolveBlock)resolve
51+
reject:(RCTPromiseRejectBlock)reject) {
52+
[self.nativeBridge resumeSessionWithResolve:resolve reject:reject];
53+
}
54+
55+
5056
RCT_EXPORT_METHOD(clearCredentials:(RCTPromiseResolveBlock)resolve
5157
reject:(RCTPromiseRejectBlock)reject) {
5258
[self.nativeBridge clearCredentialsWithResolve:resolve reject:reject];

ios/NativeBridge.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,12 @@ public class NativeBridge: NSObject {
175175
@objc public func cancelWebAuth(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
176176
resolve(WebAuthentication.cancel())
177177
}
178+
179+
// iOS does not lose the in-flight web auth result to process death the way Android can,
180+
// so there is never a session to recover here. Resolve nil to satisfy the shared contract.
181+
@objc public func resumeSession(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
182+
resolve(nil)
183+
}
178184

179185
@objc public func saveCredentials(credentialsDict: [String: Any], resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
180186

src/core/interfaces/IWebAuthProvider.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,19 @@ export interface IWebAuthProvider {
7979
* @returns A promise that resolves when the operation is complete.
8080
*/
8181
cancelWebAuth(): Promise<void>;
82+
83+
/**
84+
* Recovers a Universal Login result that completed after the OS killed the app
85+
* process mid-login (Android process death).
86+
*
87+
* @remarks
88+
* **Platform specific:** On Android, if the process is killed while the user is in
89+
* the login browser, the original `authorize()` promise is lost. The recovered
90+
* credentials are cached natively; call this on app launch to claim them. Resolves
91+
* `null` when there is nothing to recover. On iOS and web this is a no-op that
92+
* resolves `null`, since they do not have this class of failure.
93+
*
94+
* @returns A promise that resolves with the recovered credentials, or null.
95+
*/
96+
resumeSession(): Promise<Credentials | null>;
8297
}

0 commit comments

Comments
 (0)