Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -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.
67 changes: 67 additions & 0 deletions android/src/main/java/com/auth0/react/A0Auth0Module.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -131,6 +134,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
WebAuthProvider.useDPoP(reactContext)
}
webAuthPromise = promise
WebAuthRecovery.markFlowInProgress(reactContext)
val cleanedParameters = mutableMapOf<String, String>()

additionalParameters?.let { params ->
Expand Down Expand Up @@ -169,12 +173,14 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0
builder.start(reactContext.currentActivity as Activity,
object : com.auth0.android.callback.Callback<Credentials, AuthenticationException> {
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
}
Expand Down Expand Up @@ -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"
Expand All @@ -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<Credentials, AuthenticationException> {
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)
Expand Down
42 changes: 42 additions & 0 deletions android/src/main/java/com/auth0/react/WebAuthRecovery.kt
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 4 additions & 0 deletions example/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions example/ios/Auth0Example.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down
4 changes: 2 additions & 2 deletions example/ios/Podfile.lock
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
PODS:
- A0Auth0 (5.6.0):
- A0Auth0 (5.7.0):
- Auth0 (= 2.21.2)
- hermes-engine
- RCTRequired
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion ios/A0Auth0.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
6 changes: 6 additions & 0 deletions ios/NativeBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Expand Down
15 changes: 15 additions & 0 deletions src/core/interfaces/IWebAuthProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,19 @@ export interface IWebAuthProvider {
* @returns A promise that resolves when the operation is complete.
*/
cancelWebAuth(): Promise<void>;

/**
* 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<Credentials | null>;
}
31 changes: 24 additions & 7 deletions src/hooks/Auth0Provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}
}
}
Expand Down
Loading
Loading