Skip to content

Commit 1b1b1a0

Browse files
feat: Add resumeSession() to recover Web Auth logins after Android process death (#1566)
1 parent 6fa458d commit 1b1b1a0

22 files changed

Lines changed: 415 additions & 24 deletions

File tree

EXAMPLES.md

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@
9191
- [Allowed Browsers (Android)](#allowed-browsers-android)
9292
- [Using with Hooks](#using-with-hooks)
9393
- [Using with Auth0 Class](#using-with-auth0-class-1)
94+
- [Recovering Login After Process Death (Android)](#recovering-login-after-process-death-android)
95+
- [Using with Hooks](#recovering-login-using-hooks)
96+
- [Using with Auth0 Class](#recovering-login-using-the-auth0-class)
9497
- [DPoP (Demonstrating Proof-of-Possession)](#dpop-demonstrating-proof-of-possession)
9598
- [Enabling DPoP](#enabling-dpop)
9699
- [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
145148
import Auth0, { parseIdToken } from 'react-native-auth0';
146149

147150
const auth0 = new Auth0({ domain, clientId });
148-
const credentials = await auth0.webAuth.authorize({ scope: 'openid profile email' });
151+
const credentials = await auth0.webAuth.authorize({
152+
scope: 'openid profile email',
153+
});
149154
const user = parseIdToken(credentials.idToken);
150155
// user.sub, user.name, user.email, etc.
151156
```
@@ -1977,6 +1982,60 @@ await auth0.webAuth.authorize(
19771982
19781983
The same `allowedBrowserPackages` option is also accepted by `clearSession` to restrict which browser handles the logout flow.
19791984
1985+
## Recovering Login After Process Death (Android)
1986+
1987+
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.
1988+
1989+
`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).
1990+
1991+
> 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.
1992+
1993+
### Recovering Login Using Hooks
1994+
1995+
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.
1996+
1997+
```js
1998+
import { useEffect } from 'react';
1999+
import { useAuth0 } from 'react-native-auth0';
2000+
2001+
const App = () => {
2002+
const { resumeSession } = useAuth0();
2003+
2004+
useEffect(() => {
2005+
resumeSession()
2006+
.then((credentials) => {
2007+
if (credentials) {
2008+
// A login interrupted by process death was recovered.
2009+
console.log('Recovered session', credentials.accessToken);
2010+
}
2011+
})
2012+
.catch((error) => {
2013+
console.log('Failed to recover session', error);
2014+
});
2015+
}, [resumeSession]);
2016+
2017+
// ...
2018+
};
2019+
```
2020+
2021+
### Recovering Login Using the Auth0 Class
2022+
2023+
```js
2024+
import Auth0 from 'react-native-auth0';
2025+
2026+
const auth0 = new Auth0({
2027+
domain: 'YOUR_AUTH0_DOMAIN',
2028+
clientId: 'YOUR_AUTH0_CLIENT_ID',
2029+
});
2030+
2031+
// Call once on cold start, before showing the login screen.
2032+
const credentials = await auth0.webAuth.resumeSession();
2033+
if (credentials) {
2034+
await auth0.credentialsManager.saveCredentials(credentials);
2035+
// The user is logged in — route them into the app.
2036+
}
2037+
```
2038+
19802039
## DPoP (Demonstrating Proof-of-Possession)
19812040
19822041
[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.

FAQ.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,24 @@ See these issues for more information:
159159
- [possibility to run with launchMode:singleTop?](https://github.com/auth0/react-native-auth0/issues/170)
160160
- [Android singleTask launch mode is required for react-native deep links](https://github.com/auth0/react-native-auth0/issues/556)
161161

162-
### The solution
162+
### Recovering the login (recommended)
163+
164+
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:
165+
166+
```js
167+
// With the Auth0 class
168+
const credentials = await auth0.webAuth.resumeSession();
169+
170+
// Or with hooks
171+
const { resumeSession } = useAuth0();
172+
useEffect(() => {
173+
resumeSession();
174+
}, [resumeSession]);
175+
```
176+
177+
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.
178+
179+
### The solution (for `singleTask` launch mode)
163180

164181
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.
165182

android/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ dependencies {
9696
implementation "com.facebook.react:react-android"
9797
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
9898
implementation "androidx.browser:browser:1.2.0"
99-
implementation 'com.auth0.android:auth0:3.18.0'
99+
implementation 'com.auth0.android:auth0:3.19.0'
100100
}
101101

102102
if (isNewArchitectureEnabled()) {

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

Lines changed: 101 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ 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
9+
import androidx.lifecycle.LifecycleOwner
710
import com.auth0.android.Auth0
811
import com.auth0.android.authentication.AuthenticationAPIClient
912
import com.auth0.android.authentication.AuthenticationException
@@ -38,6 +41,11 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
3841

3942
companion object {
4043
const val NAME = "A0Auth0"
44+
45+
// Grace window (ms) for an in-flight restored token exchange to complete before
46+
// resumeWebAuthSession resolves null.
47+
private const val RESUME_SESSION_GRACE_MS = 5000L
48+
4149
private const val CREDENTIAL_MANAGER_ERROR_CODE = "CREDENTIAL_MANAGER_ERROR"
4250
private const val BIOMETRICS_AUTHENTICATION_ERROR_CODE = "BIOMETRICS_CONFIGURATION_ERROR"
4351

@@ -166,19 +174,86 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
166174
}
167175

168176
builder.withParameters(cleanedParameters)
169-
builder.start(reactContext.currentActivity as Activity,
170-
object : com.auth0.android.callback.Callback<Credentials, AuthenticationException> {
177+
178+
val activity = reactContext.currentActivity
179+
if (activity == null) {
180+
promise.reject("a0.activity_not_available", "Current Activity is not available")
181+
webAuthPromise = null
182+
return
183+
}
184+
// start() registers a LifecycleObserver internally (Auth0.Android 3.19.0+ for
185+
// process-death recovery), which must happen on the main thread.
186+
UiThreadUtil.runOnUiThread {
187+
builder.start(activity,
188+
object : com.auth0.android.callback.Callback<Credentials, AuthenticationException> {
189+
override fun onSuccess(result: Credentials) {
190+
val map = CredentialsParser.toMap(result)
191+
promise.resolve(map)
192+
webAuthPromise = null
193+
}
194+
195+
override fun onFailure(error: AuthenticationException) {
196+
handleError(error, promise)
197+
webAuthPromise = null
198+
}
199+
})
200+
}
201+
}
202+
203+
@ReactMethod
204+
override fun resumeWebAuthSession(promise: Promise) {
205+
val activity = reactContext.currentActivity
206+
if (activity !is LifecycleOwner) {
207+
// No lifecycle owner to register against; nothing to recover.
208+
promise.resolve(null)
209+
return
210+
}
211+
val lifecycleOwner = activity as LifecycleOwner
212+
213+
UiThreadUtil.runOnUiThread {
214+
val resolved = java.util.concurrent.atomic.AtomicBoolean(false)
215+
// Holds the pending grace-window timeout so a callback that settles the promise
216+
// first can cancel it, releasing the retained promise/activity references instead
217+
// of leaking them until the delay elapses.
218+
val timeoutHandler = Handler(Looper.getMainLooper())
219+
val loginCallback = object :
220+
com.auth0.android.callback.Callback<Credentials, AuthenticationException> {
171221
override fun onSuccess(result: Credentials) {
172-
val map = CredentialsParser.toMap(result)
173-
promise.resolve(map)
174-
webAuthPromise = null
222+
if (resolved.compareAndSet(false, true)) {
223+
timeoutHandler.removeCallbacksAndMessages(null)
224+
promise.resolve(CredentialsParser.toMap(result))
225+
}
175226
}
176227

177228
override fun onFailure(error: AuthenticationException) {
178-
handleError(error, promise)
179-
webAuthPromise = null
229+
if (resolved.compareAndSet(false, true)) {
230+
timeoutHandler.removeCallbacksAndMessages(null)
231+
handleError(error, promise)
232+
}
180233
}
181-
})
234+
}
235+
// Logout recovery is out of scope; provide a no-op logout callback.
236+
val logoutCallback = object :
237+
com.auth0.android.callback.Callback<Void?, AuthenticationException> {
238+
override fun onSuccess(result: Void?) {}
239+
override fun onFailure(error: AuthenticationException) {}
240+
}
241+
242+
// Registering against an already-RESUMED owner synchronously replays onResume,
243+
// draining any result buffered after process-death recovery.
244+
WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback, logoutCallback)
245+
246+
// Safety net for the rare case where the restored token exchange is still in
247+
// flight: give it a short grace window, then resolve null if nothing arrived.
248+
// The login callback cancels this timeout if it settles first.
249+
if (!resolved.get()) {
250+
timeoutHandler.postDelayed({
251+
if (resolved.compareAndSet(false, true)) {
252+
promise.resolve(null)
253+
}
254+
}, RESUME_SESSION_GRACE_MS)
255+
}
256+
}
182257
}
183258

184259
@ReactMethod
@@ -392,16 +467,25 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
392467
)
393468
}
394469

395-
builder.start(reactContext.currentActivity as FragmentActivity,
396-
object : com.auth0.android.callback.Callback<Void?, AuthenticationException> {
397-
override fun onSuccess(result: Void?) {
398-
promise.resolve(true)
399-
}
470+
val activity = reactContext.currentActivity
471+
if (activity !is FragmentActivity) {
472+
promise.reject("a0.activity_not_available", "Current Activity is not a FragmentActivity")
473+
return
474+
}
475+
// start() registers a LifecycleObserver internally (Auth0.Android 3.19.0+),
476+
// which must happen on the main thread.
477+
UiThreadUtil.runOnUiThread {
478+
builder.start(activity,
479+
object : com.auth0.android.callback.Callback<Void?, AuthenticationException> {
480+
override fun onSuccess(result: Void?) {
481+
promise.resolve(true)
482+
}
400483

401-
override fun onFailure(e: AuthenticationException) {
402-
handleError(e, promise)
403-
}
404-
})
484+
override fun onFailure(e: AuthenticationException) {
485+
handleError(e, promise)
486+
}
487+
})
488+
}
405489
}
406490

407491
@ReactMethod

android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ abstract class A0Auth0Spec(context: ReactApplicationContext) : ReactContextBaseJ
9696
@DoNotStrip
9797
abstract fun resumeWebAuth(url: String, promise: Promise)
9898

99+
@ReactMethod
100+
@DoNotStrip
101+
abstract fun resumeWebAuthSession(promise: Promise)
102+
99103
@ReactMethod
100104
@DoNotStrip
101105
abstract fun cancelWebAuth(promise: Promise)

example/android/app/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,8 @@ dependencies {
116116
} else {
117117
implementation jscFlavor
118118
}
119+
120+
// Required by PasskeyModule.kt (androidx.credentials.*)
121+
implementation("androidx.credentials:credentials:1.3.0")
122+
implementation("androidx.credentials:credentials-play-services-auth:1.3.0")
119123
}

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

Lines changed: 12 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,17 @@ class MainActivity : ReactActivity() {
1314
*/
1415
override fun getMainComponentName(): String = "Auth0Example"
1516

17+
/**
18+
* react-native-screens requires that fragment state is never restored, otherwise the app
19+
* crashes with "Screen fragments should never be restored" when the OS recreates the
20+
* Activity (e.g. after the Auth0 redirect or process death). Passing null discards the
21+
* saved fragment hierarchy so React Native rebuilds it fresh.
22+
* See https://github.com/software-mansion/react-native-screens/issues/17
23+
*/
24+
override fun onCreate(savedInstanceState: Bundle?) {
25+
super.onCreate(null)
26+
}
27+
1628
/**
1729
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
1830
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]

example/src/navigation/HooksDemoNavigator.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react';
1+
import React, { useEffect } from 'react';
22
import {
33
Auth0Provider,
44
useAuth0,
@@ -8,7 +8,7 @@ import {
88
} from 'react-native-auth0';
99
import AuthStackNavigator from './AuthStackNavigator';
1010
import MainTabNavigator from './MainTabNavigator';
11-
import { ActivityIndicator, View, StyleSheet } from 'react-native';
11+
import { ActivityIndicator, View, StyleSheet, Platform } from 'react-native';
1212
import config from '../auth0-configuration';
1313

1414
const AUTH0_DOMAIN = config.domain;
@@ -20,7 +20,26 @@ const AUTH0_CLIENT_ID = config.clientId;
2020
* It's rendered inside the Auth0Provider so it can use the useAuth0 hook.
2121
*/
2222
const AppContent = () => {
23-
const { user, isLoading } = useAuth0();
23+
const { user, isLoading, resumeSession } = useAuth0();
24+
25+
// On Android the OS can kill the app process while the user is completing
26+
// login in the browser. When the app cold-starts, resumeSession() recovers
27+
// any login that finished after the process was killed. It is a safe no-op
28+
// that resolves null on iOS and web, so it can be called unconditionally.
29+
useEffect(() => {
30+
if (Platform.OS !== 'android') {
31+
return;
32+
}
33+
resumeSession()
34+
.then((credentials) => {
35+
if (credentials) {
36+
console.log('Recovered login after process death');
37+
}
38+
})
39+
.catch((e) => {
40+
console.warn('resumeSession failed', e);
41+
});
42+
}, [resumeSession]);
2443

2544
if (isLoading) {
2645
return (

0 commit comments

Comments
 (0)