From aa8db365a9155cf6d891db33bb93f0219c9a7e28 Mon Sep 17 00:00:00 2001 From: Francesco-Voto Date: Wed, 23 Sep 2026 11:53:32 +0200 Subject: [PATCH 1/5] feat: allow to resume the request if androdi OS kills the process --- docs/docs/usage/authorization.md | 44 ++++++ .../java/com/rnappauth/RNAppAuthModule.java | 128 ++++++++++++++++-- packages/react-native-app-auth/index.d.ts | 11 ++ packages/react-native-app-auth/index.js | 23 ++++ 4 files changed, 192 insertions(+), 14 deletions(-) diff --git a/docs/docs/usage/authorization.md b/docs/docs/usage/authorization.md index 506cca414..2b08ac3f7 100644 --- a/docs/docs/usage/authorization.md +++ b/docs/docs/usage/authorization.md @@ -35,3 +35,47 @@ This is the result from the auth server: - **scopes** - ([`string`]) the scopes the user has agreed to be granted - **authorizationCode** - (`string`) the authorization code (only if `skipCodeExchange=true`) - **codeVerifier** - (`string`) the codeVerifier value used for the PKCE exchange (only if both `skipCodeExchange=true` and `usePKCE=true`) + +## Resuming an interrupted authorization (Android) + +On Android, the OS can kill your app's process while the user is away in the browser completing +the login (e.g. under memory pressure). When the user returns, React Native drops the resulting +activity result because it arrives before the JS context is ready +([facebook/react-native#30277](https://github.com/facebook/react-native/issues/30277)), so the +in-flight `authorize()` promise never resolves or rejects. + +`resumePendingAuthorize` lets you recover from this: it claims the stashed result and completes +the token exchange. It resolves `null` when there is nothing to resume, so it's safe to call +unconditionally on every app start, and it always resolves `null` on iOS. + +```js +import { resumePendingAuthorize } from 'react-native-app-auth'; + +const result = await resumePendingAuthorize(config); +if (result) { + // an interrupted authorize() was completed +} +``` + +This requires your `MainActivity` to forward the raw activity result to +`RNAppAuthModule` before React Native's normal handling has a chance to drop it: + +```kotlin +import com.rnappauth.RNAppAuthModule + +class MainActivity : ReactActivity() { + // ... + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == RNAppAuthModule.AUTHORIZATION_REQUEST_CODE) { + RNAppAuthModule.stashAuthorizationResult(data) + } + super.onActivityResult(requestCode, resultCode, data) + } +} +``` + +#### `config` + +Accepts the same `additionalParameters`, `dangerouslyAllowInsecureHttpRequests`, `customHeaders` +and `connectionTimeoutSeconds` options as `authorize`. diff --git a/packages/react-native-app-auth/android/src/main/java/com/rnappauth/RNAppAuthModule.java b/packages/react-native-app-auth/android/src/main/java/com/rnappauth/RNAppAuthModule.java index f1e955879..3e4548526 100644 --- a/packages/react-native-app-auth/android/src/main/java/com/rnappauth/RNAppAuthModule.java +++ b/packages/react-native-app-auth/android/src/main/java/com/rnappauth/RNAppAuthModule.java @@ -65,11 +65,20 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; public class RNAppAuthModule extends ReactContextBaseJavaModule implements ActivityEventListener { public static final String CUSTOM_TAB_PACKAGE_NAME = "com.android.chrome"; + public static final int AUTHORIZATION_REQUEST_CODE = 52; + + private static final AtomicReference sStashedAuthorizationResult = new AtomicReference<>(); + + public static void stashAuthorizationResult(Intent data) { + sStashedAuthorizationResult.set(data); + } + private final ReactApplicationContext reactContext; private Promise promise; private boolean dangerouslyAllowInsecureHttpRequests; @@ -484,13 +493,73 @@ public void onFetchConfigurationCompleted( } } - /* - * Called when the OAuth browser activity completes - */ + @ReactMethod + public void resumePendingAuthorize( + final ReadableMap additionalParameters, + final Double connectionTimeoutMillis, + final ReadableMap customHeaders, + final boolean dangerouslyAllowInsecureHttpRequests, + final Promise promise) { + final Intent data = sStashedAuthorizationResult.getAndSet(null); + + if (data == null) { + promise.resolve(null); + return; + } + + try { + final AuthorizationException authException = AuthorizationException.fromIntent(data); + if (authException != null) { + handleAuthorizationException("authentication_error", authException, promise); + return; + } + + final AuthorizationResponse response = AuthorizationResponse.fromIntent(data); + if (response == null) { + promise.resolve(null); + return; + } + + this.parseHeaderMap(customHeaders); + final AppAuthConfiguration configuration = createAppAuthConfiguration( + createConnectionBuilder(dangerouslyAllowInsecureHttpRequests, this.tokenRequestHeaders, + connectionTimeoutMillis), + dangerouslyAllowInsecureHttpRequests, + null + ); + + final AuthorizationService authService = new AuthorizationService(this.reactContext, configuration); + + final Map additionalParametersMap = MapUtil.readableMapToHashMap(additionalParameters); + final TokenRequest tokenRequest = additionalParametersMap.isEmpty() + ? response.createTokenExchangeRequest() + : response.createTokenExchangeRequest(additionalParametersMap); + + authService.performTokenRequest(tokenRequest, new AuthorizationService.TokenResponseCallback() { + @Override + public void onTokenRequestCompleted(TokenResponse resp, AuthorizationException ex) { + authService.dispose(); + if (resp != null) { + promise.resolve(TokenResponseFactory.tokenResponseToMap(resp, response)); + } else { + handleAuthorizationException("token_exchange_failed", ex, promise); + } + } + }); + } catch (Exception e) { + promise.reject("run_time_exception", e.getMessage()); + } + } + @Override public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) { try { - if (requestCode == 52) { + if (requestCode == AUTHORIZATION_REQUEST_CODE) { + if (this.promise == null) { + return; + } + sStashedAuthorizationResult.set(null); + if (data == null) { if (promise != null) { promise.reject("authentication_error", "Data intent is null" ); @@ -737,21 +806,52 @@ private void authorizeWithConfiguration( AuthorizationRequest authRequest = authRequestBuilder.build(); if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) { - AuthorizationService authService = new AuthorizationService(context, appAuthConfiguration); + final Promise authorizePromise = this.promise; - CustomTabsIntent.Builder intentBuilder = authService.createCustomTabsIntentBuilder(); - CustomTabsIntent customTabsIntent = intentBuilder.setEphemeralBrowsingEnabled(androidPrefersEphemeralSession).build(); - - if (androidTrustedWebActivity) { - customTabsIntent.intent.putExtra(TrustedWebUtils.EXTRA_LAUNCH_AS_TRUSTED_WEB_ACTIVITY, true); - } + // AuthorizationService construction and createCustomTabsIntentBuilder() are @WorkerThread: + // the latter blocks on CustomTabManager's 1s browser-connection latch. Running them on the + // main thread freezes the UI for up to a second on every authorize(). + new Thread(new Runnable() { + @Override + public void run() { + try { + AuthorizationService authService = new AuthorizationService(context, appAuthConfiguration); + + CustomTabsIntent.Builder intentBuilder = authService.createCustomTabsIntentBuilder(); + CustomTabsIntent customTabsIntent = intentBuilder.setEphemeralBrowsingEnabled(androidPrefersEphemeralSession).build(); - Intent authIntent = authService.getAuthorizationRequestIntent(authRequest, customTabsIntent); + if (androidTrustedWebActivity) { + customTabsIntent.intent.putExtra(TrustedWebUtils.EXTRA_LAUNCH_AS_TRUSTED_WEB_ACTIVITY, true); + } - currentActivity.startActivityForResult(authIntent, 52); + final Intent authIntent = authService.getAuthorizationRequestIntent(authRequest, customTabsIntent); + + currentActivity.runOnUiThread(new Runnable() { + @Override + public void run() { + try { + currentActivity.startActivityForResult(authIntent, AUTHORIZATION_REQUEST_CODE); + } catch (ActivityNotFoundException e) { + if (authorizePromise != null) { + authorizePromise.reject("browser_not_found", e.getMessage()); + } + } catch (Exception e) { + if (authorizePromise != null) { + authorizePromise.reject("authentication_failed", e.getMessage()); + } + } + } + }); + } catch (Exception e) { + if (authorizePromise != null) { + authorizePromise.reject("authentication_failed", e.getMessage()); + } + } + } + }, "RNAppAuth-authorize").start(); } else { AuthorizationService authService = new AuthorizationService(currentActivity, appAuthConfiguration); - PendingIntent pendingIntent = currentActivity.createPendingResult(52, new Intent(), 0); + PendingIntent pendingIntent = currentActivity.createPendingResult(AUTHORIZATION_REQUEST_CODE, new Intent(), 0); authService.performAuthorizationRequest(authRequest, pendingIntent); } diff --git a/packages/react-native-app-auth/index.d.ts b/packages/react-native-app-auth/index.d.ts index 7ff7ec62b..1bc35fde1 100644 --- a/packages/react-native-app-auth/index.d.ts +++ b/packages/react-native-app-auth/index.d.ts @@ -148,6 +148,17 @@ export function register(config: RegistrationConfiguration): Promise; +export type ResumePendingAuthorizeConfiguration = { + additionalParameters?: { [name: string]: string }; + dangerouslyAllowInsecureHttpRequests?: boolean; + customHeaders?: CustomHeaders; + connectionTimeoutSeconds?: number; +}; + +export function resumePendingAuthorize( + config?: ResumePendingAuthorizeConfiguration +): Promise; + export function refresh( config: AuthConfiguration, refreshConfig: RefreshConfiguration diff --git a/packages/react-native-app-auth/index.js b/packages/react-native-app-auth/index.js index a49c3c58c..c5ad33466 100644 --- a/packages/react-native-app-auth/index.js +++ b/packages/react-native-app-auth/index.js @@ -274,6 +274,29 @@ export const authorize = ({ return wrapNativeAuthPromise(RNAppAuth.authorize(...nativeMethodArguments)); }; +export const resumePendingAuthorize = ({ + additionalParameters, + dangerouslyAllowInsecureHttpRequests = false, + customHeaders, + connectionTimeoutSeconds, +} = {}) => { + if (Platform.OS !== 'android') { + return Promise.resolve(null); + } + + validateHeaders(customHeaders); + validateConnectionTimeoutSeconds(connectionTimeoutSeconds); + + return wrapNativeAuthPromise( + RNAppAuth.resumePendingAuthorize( + additionalParameters, + convertTimeoutForPlatform(Platform.OS, connectionTimeoutSeconds), + customHeaders, + dangerouslyAllowInsecureHttpRequests + ) + ); +}; + export const refresh = ( { issuer, From 6b3f1d8ba4878adaa5840d5c5e726e5c42cdca1f Mon Sep 17 00:00:00 2001 From: Eli Zibin <1131641+zibs@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:20:54 -0700 Subject: [PATCH 2/5] fix(android): preserve recovered authorization options and ownership --- .changeset/android-authorization-recovery.md | 5 + docs/docs/usage/authorization.md | 13 +- .../java/com/rnappauth/RNAppAuthModule.java | 258 +++++++++--------- packages/react-native-app-auth/index.d.ts | 3 + packages/react-native-app-auth/index.js | 8 +- .../resume-pending-authorize.spec.js | 63 +++++ 6 files changed, 213 insertions(+), 137 deletions(-) create mode 100644 .changeset/android-authorization-recovery.md create mode 100644 packages/react-native-app-auth/resume-pending-authorize.spec.js diff --git a/.changeset/android-authorization-recovery.md b/.changeset/android-authorization-recovery.md new file mode 100644 index 000000000..53c8ff09e --- /dev/null +++ b/.changeset/android-authorization-recovery.md @@ -0,0 +1,5 @@ +--- +"react-native-app-auth": minor +--- + +Add `resumePendingAuthorize` to recover Android browser authorization after process termination, preserving client authentication and skipped code exchanges. Move Custom Tab preparation off the UI thread. diff --git a/docs/docs/usage/authorization.md b/docs/docs/usage/authorization.md index 2b08ac3f7..695ce1bc4 100644 --- a/docs/docs/usage/authorization.md +++ b/docs/docs/usage/authorization.md @@ -61,6 +61,7 @@ This requires your `MainActivity` to forward the raw activity result to `RNAppAuthModule` before React Native's normal handling has a chance to drop it: ```kotlin +import android.content.Intent import com.rnappauth.RNAppAuthModule class MainActivity : ReactActivity() { @@ -77,5 +78,13 @@ class MainActivity : ReactActivity() { #### `config` -Accepts the same `additionalParameters`, `dangerouslyAllowInsecureHttpRequests`, `customHeaders` -and `connectionTimeoutSeconds` options as `authorize`. +Pass the original authorization configuration when resuming. `additionalParameters`, +`clientSecret`, `clientAuthMethod`, `skipCodeExchange`, `dangerouslyAllowInsecureHttpRequests`, +`customHeaders`, and `connectionTimeoutSeconds` keep the same meaning as in `authorize`. +With `skipCodeExchange: true`, recovery returns the authorization code and original PKCE +verifier without exchanging the code. Client secrets and exchange options are supplied by +your app; they are not saved in the browser result. + +Call this before starting a new authorization. A pending result is claimed once: subsequent +calls return `null`, including after an exchange failure. Start a new authorization if recovery +fails. A live `authorize()` owns its own result and is not recovered by this API. diff --git a/packages/react-native-app-auth/android/src/main/java/com/rnappauth/RNAppAuthModule.java b/packages/react-native-app-auth/android/src/main/java/com/rnappauth/RNAppAuthModule.java index 3e4548526..5ac7d4fc6 100644 --- a/packages/react-native-app-auth/android/src/main/java/com/rnappauth/RNAppAuthModule.java +++ b/packages/react-native-app-auth/android/src/main/java/com/rnappauth/RNAppAuthModule.java @@ -26,6 +26,7 @@ import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.WritableMap; import com.facebook.react.bridge.ReadableType; +import com.facebook.react.bridge.UiThreadUtil; import com.rnappauth.utils.MapUtil; import com.rnappauth.utils.MutableBrowserAllowList; @@ -75,12 +76,13 @@ public class RNAppAuthModule extends ReactContextBaseJavaModule implements Activ private static final AtomicReference sStashedAuthorizationResult = new AtomicReference<>(); - public static void stashAuthorizationResult(Intent data) { + public static void stashAuthorizationResult(final Intent data) { sStashedAuthorizationResult.set(data); } private final ReactApplicationContext reactContext; private Promise promise; + private final AtomicReference pendingAuthorizePromise = new AtomicReference<>(); private boolean dangerouslyAllowInsecureHttpRequests; private Boolean skipCodeExchange; private Boolean usePKCE; @@ -255,6 +257,10 @@ public void authorize( final boolean androidTrustedWebActivity, final boolean androidPrefersEphemeralSession, final Promise promise) { + if (!pendingAuthorizePromise.compareAndSet(null, promise)) { + promise.reject("authentication_in_progress", "An authorization is already pending"); + return; + } this.parseHeaderMap(customHeaders); final ConnectionBuilder builder = createConnectionBuilder(dangerouslyAllowInsecureHttpRequests, this.authorizationRequestHeaders, connectionTimeoutMillis); @@ -263,7 +269,6 @@ public void authorize( final HashMap additionalParametersMap = MapUtil.readableMapToHashMap(additionalParameters); // store args in private fields for later use in onActivityResult handler - this.promise = promise; this.dangerouslyAllowInsecureHttpRequests = dangerouslyAllowInsecureHttpRequests; this.additionalParametersMap = additionalParametersMap; this.clientSecret = clientSecret; @@ -291,8 +296,10 @@ public void authorize( androidTrustedWebActivity, androidPrefersEphemeralSession); } catch (ActivityNotFoundException e) { + pendingAuthorizePromise.compareAndSet(promise, null); promise.reject("browser_not_found", e.getMessage()); } catch (Exception e) { + pendingAuthorizePromise.compareAndSet(promise, null); promise.reject("authentication_failed", e.getMessage()); } } else { @@ -304,6 +311,7 @@ public void onFetchConfigurationCompleted( @Nullable AuthorizationServiceConfiguration fetchedConfiguration, @Nullable AuthorizationException ex) { if (ex != null) { + pendingAuthorizePromise.compareAndSet(promise, null); promise.reject("service_configuration_fetch_error", ex.getLocalizedMessage(), ex); return; } @@ -323,8 +331,10 @@ public void onFetchConfigurationCompleted( androidTrustedWebActivity, androidPrefersEphemeralSession); } catch (ActivityNotFoundException e) { + pendingAuthorizePromise.compareAndSet(promise, null); promise.reject("browser_not_found", e.getMessage()); } catch (Exception e) { + pendingAuthorizePromise.compareAndSet(promise, null); promise.reject("authentication_failed", e.getMessage()); } } @@ -499,170 +509,135 @@ public void resumePendingAuthorize( final Double connectionTimeoutMillis, final ReadableMap customHeaders, final boolean dangerouslyAllowInsecureHttpRequests, + final String clientSecret, + final String clientAuthMethod, + final boolean skipCodeExchange, final Promise promise) { + // A live browser flow belongs to authorize(), even if the host has forwarded its result. + if (pendingAuthorizePromise.get() != null) { + promise.resolve(null); + return; + } final Intent data = sStashedAuthorizationResult.getAndSet(null); - if (data == null) { promise.resolve(null); return; } + final Map tokenHeaders = customHeaders != null && customHeaders.hasKey("token") + ? MapUtil.readableMapToHashMap(customHeaders.getMap("token")) : null; + final AppAuthConfiguration configuration = createAppAuthConfiguration( + createConnectionBuilder(dangerouslyAllowInsecureHttpRequests, tokenHeaders, connectionTimeoutMillis), + dangerouslyAllowInsecureHttpRequests, null); + completeAuthorization(data, skipCodeExchange, clientSecret, clientAuthMethod, + MapUtil.readableMapToHashMap(additionalParameters), configuration, promise); + } + + private void completeAuthorization( + final Intent data, + final boolean skipCodeExchange, + final String clientSecret, + final String clientAuthMethod, + final Map additionalParameters, + final AppAuthConfiguration configuration, + final Promise resultPromise) { try { - final AuthorizationException authException = AuthorizationException.fromIntent(data); - if (authException != null) { - handleAuthorizationException("authentication_error", authException, promise); + if (data == null) { + resultPromise.reject("authentication_error", "Data intent is null"); + return; + } + final AuthorizationException exception = AuthorizationException.fromIntent(data); + if (exception != null) { + handleAuthorizationException("authentication_error", exception, resultPromise); return; } - final AuthorizationResponse response = AuthorizationResponse.fromIntent(data); if (response == null) { - promise.resolve(null); + resultPromise.reject("authentication_error", "No authorization response in activity result"); + return; + } + if (skipCodeExchange) { + final String verifier = response.request.codeVerifier; + resultPromise.resolve(verifier != null + ? TokenResponseFactory.authorizationCodeResponseToMap(response, verifier) + : TokenResponseFactory.authorizationResponseToMap(response)); return; } - this.parseHeaderMap(customHeaders); - final AppAuthConfiguration configuration = createAppAuthConfiguration( - createConnectionBuilder(dangerouslyAllowInsecureHttpRequests, this.tokenRequestHeaders, - connectionTimeoutMillis), - dangerouslyAllowInsecureHttpRequests, - null - ); - + final Map exchangeParameters = new HashMap<>(); + if (additionalParameters != null) { + exchangeParameters.putAll(additionalParameters); + } + // Match authorizeWithConfiguration: these options belong to the browser request. + for (String parameter : new String[]{"display", "login_hint", "prompt", "state", "nonce", "ui_locales", "response_mode"}) { + exchangeParameters.remove(parameter); + } + final TokenRequest tokenRequest = response.createTokenExchangeRequest(exchangeParameters); final AuthorizationService authService = new AuthorizationService(this.reactContext, configuration); - - final Map additionalParametersMap = MapUtil.readableMapToHashMap(additionalParameters); - final TokenRequest tokenRequest = additionalParametersMap.isEmpty() - ? response.createTokenExchangeRequest() - : response.createTokenExchangeRequest(additionalParametersMap); - - authService.performTokenRequest(tokenRequest, new AuthorizationService.TokenResponseCallback() { + final AuthorizationService.TokenResponseCallback callback = new AuthorizationService.TokenResponseCallback() { @Override - public void onTokenRequestCompleted(TokenResponse resp, AuthorizationException ex) { + public void onTokenRequestCompleted(final TokenResponse responseToken, final AuthorizationException error) { authService.dispose(); - if (resp != null) { - promise.resolve(TokenResponseFactory.tokenResponseToMap(resp, response)); + if (responseToken != null) { + resultPromise.resolve(TokenResponseFactory.tokenResponseToMap(responseToken, response)); } else { - handleAuthorizationException("token_exchange_failed", ex, promise); + handleAuthorizationException("token_exchange_failed", error, resultPromise); } } - }); - } catch (Exception e) { - promise.reject("run_time_exception", e.getMessage()); + }; + try { + if (clientSecret != null) { + authService.performTokenRequest(tokenRequest, getClientAuthentication(clientSecret, clientAuthMethod), callback); + } else { + authService.performTokenRequest(tokenRequest, callback); + } + } catch (Exception error) { + authService.dispose(); + throw error; + } + } catch (Exception error) { + resultPromise.reject("run_time_exception", error.getMessage()); } } @Override public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) { - try { if (requestCode == AUTHORIZATION_REQUEST_CODE) { - if (this.promise == null) { - return; - } - sStashedAuthorizationResult.set(null); - - if (data == null) { - if (promise != null) { - promise.reject("authentication_error", "Data intent is null" ); - } - return; - } - - final AuthorizationResponse response = AuthorizationResponse.fromIntent(data); - AuthorizationException ex = AuthorizationException.fromIntent(data); - if (ex != null) { - if (promise != null) { - handleAuthorizationException("authentication_error", ex, promise); - } - return; - } - - if (this.skipCodeExchange != null && this.skipCodeExchange) { - WritableMap map; - if (this.usePKCE != null && this.usePKCE && this.codeVerifier != null) { - map = TokenResponseFactory.authorizationCodeResponseToMap(response, this.codeVerifier); - } else { - map = TokenResponseFactory.authorizationResponseToMap(response); - } - - if (promise != null) { - promise.resolve(map); - } + final Promise authorizePromise = pendingAuthorizePromise.getAndSet(null); + if (authorizePromise == null) { + stashAuthorizationResult(data); return; } - - - final Promise authorizePromise = this.promise; + sStashedAuthorizationResult.compareAndSet(data, null); final AppAuthConfiguration configuration = createAppAuthConfiguration( createConnectionBuilder(this.dangerouslyAllowInsecureHttpRequests, this.tokenRequestHeaders), - this.dangerouslyAllowInsecureHttpRequests, - null - ); - - AuthorizationService authService = new AuthorizationService(this.reactContext, configuration); - - TokenRequest tokenRequest; - if(this.additionalParametersMap == null) { - tokenRequest = response.createTokenExchangeRequest(); - } else { - tokenRequest = response.createTokenExchangeRequest(this.additionalParametersMap); - } - - AuthorizationService.TokenResponseCallback tokenResponseCallback = new AuthorizationService.TokenResponseCallback() { - - @Override - public void onTokenRequestCompleted( - TokenResponse resp, AuthorizationException ex) { - if (resp != null) { - WritableMap map = TokenResponseFactory.tokenResponseToMap(resp, response); - if (authorizePromise != null) { - authorizePromise.resolve(map); - } - } else { - if (promise != null) { - handleAuthorizationException("token_exchange_failed", ex, promise); - } - } - } - }; - - if (this.clientSecret != null) { - ClientAuthentication clientAuth = this.getClientAuthentication(this.clientSecret, this.clientAuthMethod); - authService.performTokenRequest(tokenRequest, clientAuth, tokenResponseCallback); - - } else { - authService.performTokenRequest(tokenRequest, tokenResponseCallback); - } - - } // close if + this.dangerouslyAllowInsecureHttpRequests, null); + completeAuthorization(data, Boolean.TRUE.equals(this.skipCodeExchange), this.clientSecret, + this.clientAuthMethod, this.additionalParametersMap, configuration, authorizePromise); + return; + } if (requestCode == 53) { - if (data == null) { - if (promise != null) { - promise.reject("end_session_failed", "Data intent is null" ); - } + final Promise endSessionPromise = this.promise; + this.promise = null; + if (endSessionPromise == null) { return; } - EndSessionResponse response = EndSessionResponse.fromIntent(data); - AuthorizationException ex = AuthorizationException.fromIntent(data); - if (ex != null) { - if (promise != null) { - handleAuthorizationException("end_session_failed", ex, promise); + try { + if (data == null) { + endSessionPromise.reject("end_session_failed", "Data intent is null"); + return; } - return; - } - final Promise endSessionPromise = this.promise; - if (endSessionPromise != null) { - WritableMap map = EndSessionResponseFactory.endSessionResponseToMap(response); - endSessionPromise.resolve(map); + final AuthorizationException exception = AuthorizationException.fromIntent(data); + if (exception != null) { + handleAuthorizationException("end_session_failed", exception, endSessionPromise); + return; + } + endSessionPromise.resolve(EndSessionResponseFactory.endSessionResponseToMap(EndSessionResponse.fromIntent(data))); + } catch (Exception error) { + endSessionPromise.reject("run_time_exception", error.getMessage()); } } - } catch (Exception e) { - if(promise != null) { - promise.reject("run_time_exception", e.getMessage()); - } else { - throw e; - } - } } /* @@ -806,7 +781,7 @@ private void authorizeWithConfiguration( AuthorizationRequest authRequest = authRequestBuilder.build(); if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) { - final Promise authorizePromise = this.promise; + final Promise authorizePromise = pendingAuthorizePromise.get(); // AuthorizationService construction and createCustomTabsIntentBuilder() are @WorkerThread: // the latter blocks on CustomTabManager's 1s browser-connection latch. Running them on the @@ -817,7 +792,13 @@ public void run() { try { AuthorizationService authService = new AuthorizationService(context, appAuthConfiguration); - CustomTabsIntent.Builder intentBuilder = authService.createCustomTabsIntentBuilder(); + CustomTabsIntent.Builder intentBuilder; + try { + intentBuilder = authService.createCustomTabsIntentBuilder(); + } catch (Exception error) { + authService.dispose(); + throw error; + } CustomTabsIntent customTabsIntent = intentBuilder.setEphemeralBrowsingEnabled(androidPrefersEphemeralSession).build(); if (androidTrustedWebActivity) { @@ -826,24 +807,33 @@ public void run() { final Intent authIntent = authService.getAuthorizationRequestIntent(authRequest, customTabsIntent); - currentActivity.runOnUiThread(new Runnable() { + UiThreadUtil.runOnUiThread(new Runnable() { @Override public void run() { try { - currentActivity.startActivityForResult(authIntent, AUTHORIZATION_REQUEST_CODE); + final Activity activity = getCurrentActivity(); + if (activity == null || activity.isFinishing() || activity.isDestroyed()) { + throw new IllegalStateException("No foreground activity available for authorization"); + } + activity.startActivityForResult(authIntent, AUTHORIZATION_REQUEST_CODE); } catch (ActivityNotFoundException e) { if (authorizePromise != null) { - authorizePromise.reject("browser_not_found", e.getMessage()); + pendingAuthorizePromise.compareAndSet(authorizePromise, null); + pendingAuthorizePromise.compareAndSet(authorizePromise, null); + authorizePromise.reject("browser_not_found", e.getMessage()); } } catch (Exception e) { if (authorizePromise != null) { - authorizePromise.reject("authentication_failed", e.getMessage()); + pendingAuthorizePromise.compareAndSet(authorizePromise, null); + pendingAuthorizePromise.compareAndSet(authorizePromise, null); + authorizePromise.reject("authentication_failed", e.getMessage()); } } } }); } catch (Exception e) { if (authorizePromise != null) { + pendingAuthorizePromise.compareAndSet(authorizePromise, null); authorizePromise.reject("authentication_failed", e.getMessage()); } } diff --git a/packages/react-native-app-auth/index.d.ts b/packages/react-native-app-auth/index.d.ts index 1bc35fde1..b7171167d 100644 --- a/packages/react-native-app-auth/index.d.ts +++ b/packages/react-native-app-auth/index.d.ts @@ -150,6 +150,9 @@ export function authorize(config: AuthConfiguration): Promise; export type ResumePendingAuthorizeConfiguration = { additionalParameters?: { [name: string]: string }; + clientSecret?: string; + clientAuthMethod?: 'basic' | 'post'; + skipCodeExchange?: boolean; dangerouslyAllowInsecureHttpRequests?: boolean; customHeaders?: CustomHeaders; connectionTimeoutSeconds?: number; diff --git a/packages/react-native-app-auth/index.js b/packages/react-native-app-auth/index.js index c5ad33466..2d504185e 100644 --- a/packages/react-native-app-auth/index.js +++ b/packages/react-native-app-auth/index.js @@ -276,6 +276,9 @@ export const authorize = ({ export const resumePendingAuthorize = ({ additionalParameters, + clientSecret, + clientAuthMethod = 'basic', + skipCodeExchange = false, dangerouslyAllowInsecureHttpRequests = false, customHeaders, connectionTimeoutSeconds, @@ -292,7 +295,10 @@ export const resumePendingAuthorize = ({ additionalParameters, convertTimeoutForPlatform(Platform.OS, connectionTimeoutSeconds), customHeaders, - dangerouslyAllowInsecureHttpRequests + dangerouslyAllowInsecureHttpRequests, + clientSecret, + clientAuthMethod, + skipCodeExchange ) ); }; diff --git a/packages/react-native-app-auth/resume-pending-authorize.spec.js b/packages/react-native-app-auth/resume-pending-authorize.spec.js new file mode 100644 index 000000000..768e31d23 --- /dev/null +++ b/packages/react-native-app-auth/resume-pending-authorize.spec.js @@ -0,0 +1,63 @@ +import { NativeModules, Platform } from 'react-native'; +import { resumePendingAuthorize } from './'; + +jest.mock('react-native', () => ({ + NativeModules: { RNAppAuth: { resumePendingAuthorize: jest.fn() } }, + Platform: { OS: 'android' }, +})); + +describe('resumePendingAuthorize', () => { + const nativeResume = NativeModules.RNAppAuth.resumePendingAuthorize; + + beforeEach(() => { + Platform.OS = 'android'; + nativeResume.mockReset(); + nativeResume.mockResolvedValue(null); + }); + + it('supports an unconditional startup call with the Android defaults', async () => { + await expect(resumePendingAuthorize()).resolves.toBeNull(); + expect(nativeResume).toHaveBeenCalledWith(undefined, 15000, undefined, false, undefined, 'basic', false); + }); + + it('preserves client authentication, exchange mode and transport options', async () => { + const parameters = { audience: 'api', prompt: 'login' }; + const headers = { token: { 'X-Client': 'demo' } }; + await resumePendingAuthorize({ + additionalParameters: parameters, + clientSecret: 'fixture-secret', + clientAuthMethod: 'post', + skipCodeExchange: true, + customHeaders: headers, + dangerouslyAllowInsecureHttpRequests: true, + connectionTimeoutSeconds: 5, + }); + expect(nativeResume).toHaveBeenCalledWith(parameters, 5000, headers, true, 'fixture-secret', 'post', true); + }); + + it('returns a recovered result unchanged', async () => { + const result = { authorizationCode: 'fixture-code', codeVerifier: 'fixture-verifier' }; + nativeResume.mockResolvedValue(result); + await expect(resumePendingAuthorize({ skipCodeExchange: true })).resolves.toBe(result); + }); + + it('never calls the Android bridge on iOS', async () => { + Platform.OS = 'ios'; + await expect(resumePendingAuthorize()).resolves.toBeNull(); + expect(nativeResume).not.toHaveBeenCalled(); + }); + + it('validates transport options before claiming the native result', () => { + expect(() => resumePendingAuthorize({ customHeaders: { unknown: {} } })).toThrow(); + expect(() => resumePendingAuthorize({ connectionTimeoutSeconds: '5' })).toThrow(); + expect(nativeResume).not.toHaveBeenCalled(); + }); + + it('preserves the native error normalization used by authorize', async () => { + const error = { code: 'invalid_grant', userInfo: { nativeError: 'fixture failure' } }; + nativeResume.mockRejectedValue(error); + await expect(resumePendingAuthorize()).rejects.toMatchObject({ + code: 'invalid_grant', nativeError: 'fixture failure', + }); + }); +}); From dfa3517bdad3d31c1b762ef76c8773465dbcdedd Mon Sep 17 00:00:00 2001 From: Eli Zibin <1131641+zibs@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:21:01 -0700 Subject: [PATCH 3/5] fix(android): dispose browser services after authorization launch --- .../java/com/rnappauth/RNAppAuthModule.java | 67 +++++++++---------- 1 file changed, 33 insertions(+), 34 deletions(-) diff --git a/packages/react-native-app-auth/android/src/main/java/com/rnappauth/RNAppAuthModule.java b/packages/react-native-app-auth/android/src/main/java/com/rnappauth/RNAppAuthModule.java index 5ac7d4fc6..5129284f7 100644 --- a/packages/react-native-app-auth/android/src/main/java/com/rnappauth/RNAppAuthModule.java +++ b/packages/react-native-app-auth/android/src/main/java/com/rnappauth/RNAppAuthModule.java @@ -792,45 +792,44 @@ public void run() { try { AuthorizationService authService = new AuthorizationService(context, appAuthConfiguration); - CustomTabsIntent.Builder intentBuilder; try { - intentBuilder = authService.createCustomTabsIntentBuilder(); - } catch (Exception error) { - authService.dispose(); - throw error; - } - CustomTabsIntent customTabsIntent = intentBuilder.setEphemeralBrowsingEnabled(androidPrefersEphemeralSession).build(); + CustomTabsIntent.Builder intentBuilder = authService.createCustomTabsIntentBuilder(); + CustomTabsIntent customTabsIntent = intentBuilder.setEphemeralBrowsingEnabled(androidPrefersEphemeralSession).build(); - if (androidTrustedWebActivity) { - customTabsIntent.intent.putExtra(TrustedWebUtils.EXTRA_LAUNCH_AS_TRUSTED_WEB_ACTIVITY, true); - } - - final Intent authIntent = authService.getAuthorizationRequestIntent(authRequest, customTabsIntent); + if (androidTrustedWebActivity) { + customTabsIntent.intent.putExtra(TrustedWebUtils.EXTRA_LAUNCH_AS_TRUSTED_WEB_ACTIVITY, true); + } - UiThreadUtil.runOnUiThread(new Runnable() { - @Override - public void run() { - try { - final Activity activity = getCurrentActivity(); - if (activity == null || activity.isFinishing() || activity.isDestroyed()) { - throw new IllegalStateException("No foreground activity available for authorization"); - } - activity.startActivityForResult(authIntent, AUTHORIZATION_REQUEST_CODE); - } catch (ActivityNotFoundException e) { - if (authorizePromise != null) { - pendingAuthorizePromise.compareAndSet(authorizePromise, null); - pendingAuthorizePromise.compareAndSet(authorizePromise, null); - authorizePromise.reject("browser_not_found", e.getMessage()); - } - } catch (Exception e) { - if (authorizePromise != null) { - pendingAuthorizePromise.compareAndSet(authorizePromise, null); - pendingAuthorizePromise.compareAndSet(authorizePromise, null); - authorizePromise.reject("authentication_failed", e.getMessage()); + final Intent authIntent = authService.getAuthorizationRequestIntent(authRequest, customTabsIntent); + + UiThreadUtil.runOnUiThread(new Runnable() { + @Override + public void run() { + try { + final Activity activity = getCurrentActivity(); + if (activity == null || activity.isFinishing() || activity.isDestroyed()) { + throw new IllegalStateException("No foreground activity available for authorization"); + } + activity.startActivityForResult(authIntent, AUTHORIZATION_REQUEST_CODE); + } catch (ActivityNotFoundException e) { + if (authorizePromise != null) { + pendingAuthorizePromise.compareAndSet(authorizePromise, null); + authorizePromise.reject("browser_not_found", e.getMessage()); + } + } catch (Exception e) { + if (authorizePromise != null) { + pendingAuthorizePromise.compareAndSet(authorizePromise, null); + authorizePromise.reject("authentication_failed", e.getMessage()); + } + } finally { + authService.dispose(); } } - } - }); + }); + } catch (Exception error) { + authService.dispose(); + throw error; + } } catch (Exception e) { if (authorizePromise != null) { pendingAuthorizePromise.compareAndSet(authorizePromise, null); From 58fb4808be1439b84dc8d328a6db1833482cc9e0 Mon Sep 17 00:00:00 2001 From: Eli Zibin <1131641+zibs@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:42:35 -0700 Subject: [PATCH 4/5] fix(android): serialize recovery and preserve result ownership --- .changeset/android-result-delivery-order.md | 5 ++ .../java/com/rnappauth/RNAppAuthModule.java | 78 ++++++++++++++----- 2 files changed, 65 insertions(+), 18 deletions(-) create mode 100644 .changeset/android-result-delivery-order.md diff --git a/.changeset/android-result-delivery-order.md b/.changeset/android-result-delivery-order.md new file mode 100644 index 000000000..84401ee32 --- /dev/null +++ b/.changeset/android-result-delivery-order.md @@ -0,0 +1,5 @@ +--- +"react-native-app-auth": patch +--- + +Prevent a delayed or repeated Android activity callback from replaying an already consumed authorization result, and preserve the completed flow's exchange options when another login starts concurrently. diff --git a/packages/react-native-app-auth/android/src/main/java/com/rnappauth/RNAppAuthModule.java b/packages/react-native-app-auth/android/src/main/java/com/rnappauth/RNAppAuthModule.java index 5129284f7..2452a6dce 100644 --- a/packages/react-native-app-auth/android/src/main/java/com/rnappauth/RNAppAuthModule.java +++ b/packages/react-native-app-auth/android/src/main/java/com/rnappauth/RNAppAuthModule.java @@ -67,6 +67,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicReference; +import java.lang.ref.WeakReference; public class RNAppAuthModule extends ReactContextBaseJavaModule implements ActivityEventListener { @@ -75,9 +76,19 @@ public class RNAppAuthModule extends ReactContextBaseJavaModule implements Activ public static final int AUTHORIZATION_REQUEST_CODE = 52; private static final AtomicReference sStashedAuthorizationResult = new AtomicReference<>(); + private static final Object authorizationResultLock = new Object(); + // Remember only the last consumed delivery, without retaining its code or PKCE verifier. + private static WeakReference sConsumedAuthorizationResult = new WeakReference<>(null); public static void stashAuthorizationResult(final Intent data) { - sStashedAuthorizationResult.set(data); + synchronized (authorizationResultLock) { + if (data == null) { + sStashedAuthorizationResult.set(null); + sConsumedAuthorizationResult.clear(); + } else if (data != sConsumedAuthorizationResult.get()) { + sStashedAuthorizationResult.set(data); + } + } } private final ReactApplicationContext reactContext; @@ -257,9 +268,11 @@ public void authorize( final boolean androidTrustedWebActivity, final boolean androidPrefersEphemeralSession, final Promise promise) { - if (!pendingAuthorizePromise.compareAndSet(null, promise)) { - promise.reject("authentication_in_progress", "An authorization is already pending"); - return; + synchronized (authorizationResultLock) { + if (!pendingAuthorizePromise.compareAndSet(null, promise)) { + promise.reject("authentication_in_progress", "An authorization is already pending"); + return; + } } this.parseHeaderMap(customHeaders); final ConnectionBuilder builder = createConnectionBuilder(dangerouslyAllowInsecureHttpRequests, @@ -513,12 +526,18 @@ public void resumePendingAuthorize( final String clientAuthMethod, final boolean skipCodeExchange, final Promise promise) { - // A live browser flow belongs to authorize(), even if the host has forwarded its result. - if (pendingAuthorizePromise.get() != null) { - promise.resolve(null); - return; + final Intent data; + synchronized (authorizationResultLock) { + // Check ownership and claim the stash together across the UI and native-module queues. + if (pendingAuthorizePromise.get() != null) { + promise.resolve(null); + return; + } + data = sStashedAuthorizationResult.getAndSet(null); + if (data != null) { + sConsumedAuthorizationResult = new WeakReference<>(data); + } } - final Intent data = sStashedAuthorizationResult.getAndSet(null); if (data == null) { promise.resolve(null); return; @@ -603,17 +622,40 @@ public void onTokenRequestCompleted(final TokenResponse responseToken, final Aut @Override public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) { if (requestCode == AUTHORIZATION_REQUEST_CODE) { - final Promise authorizePromise = pendingAuthorizePromise.getAndSet(null); - if (authorizePromise == null) { - stashAuthorizationResult(data); - return; + final Promise authorizePromise; + final boolean allowInsecureConnections; + final Map tokenHeaders; + final boolean exchangeSkipped; + final String secret; + final String authenticationMethod; + final Map exchangeParameters; + synchronized (authorizationResultLock) { + if (data != null && data == sConsumedAuthorizationResult.get()) { + return; + } + authorizePromise = pendingAuthorizePromise.getAndSet(null); + if (authorizePromise == null) { + stashAuthorizationResult(data); + return; + } + // Recovery must not observe released ownership with the same result still stashed. + sStashedAuthorizationResult.compareAndSet(data, null); + if (data != null) { + sConsumedAuthorizationResult = new WeakReference<>(data); + } + // A subsequent authorize() may replace these fields as soon as ownership is released. + allowInsecureConnections = this.dangerouslyAllowInsecureHttpRequests; + tokenHeaders = this.tokenRequestHeaders; + exchangeSkipped = Boolean.TRUE.equals(this.skipCodeExchange); + secret = this.clientSecret; + authenticationMethod = this.clientAuthMethod; + exchangeParameters = this.additionalParametersMap; } - sStashedAuthorizationResult.compareAndSet(data, null); final AppAuthConfiguration configuration = createAppAuthConfiguration( - createConnectionBuilder(this.dangerouslyAllowInsecureHttpRequests, this.tokenRequestHeaders), - this.dangerouslyAllowInsecureHttpRequests, null); - completeAuthorization(data, Boolean.TRUE.equals(this.skipCodeExchange), this.clientSecret, - this.clientAuthMethod, this.additionalParametersMap, configuration, authorizePromise); + createConnectionBuilder(allowInsecureConnections, tokenHeaders), + allowInsecureConnections, null); + completeAuthorization(data, exchangeSkipped, secret, + authenticationMethod, exchangeParameters, configuration, authorizePromise); return; } From 18b6780c32779d57f443cf8cb6f8adb7bb177e4d Mon Sep 17 00:00:00 2001 From: Eli Zibin <1131641+zibs@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:29:15 -0700 Subject: [PATCH 5/5] fix: isolate recovery tests from base64 module loading Match the existing wrapper test mocks and address the platform lookup review suggestion. --- packages/react-native-app-auth/index.js | 5 +++-- .../react-native-app-auth/resume-pending-authorize.spec.js | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/react-native-app-auth/index.js b/packages/react-native-app-auth/index.js index 2d504185e..2fa2604f8 100644 --- a/packages/react-native-app-auth/index.js +++ b/packages/react-native-app-auth/index.js @@ -283,7 +283,8 @@ export const resumePendingAuthorize = ({ customHeaders, connectionTimeoutSeconds, } = {}) => { - if (Platform.OS !== 'android') { + const platform = Platform.OS; + if (platform !== 'android') { return Promise.resolve(null); } @@ -293,7 +294,7 @@ export const resumePendingAuthorize = ({ return wrapNativeAuthPromise( RNAppAuth.resumePendingAuthorize( additionalParameters, - convertTimeoutForPlatform(Platform.OS, connectionTimeoutSeconds), + convertTimeoutForPlatform(platform, connectionTimeoutSeconds), customHeaders, dangerouslyAllowInsecureHttpRequests, clientSecret, diff --git a/packages/react-native-app-auth/resume-pending-authorize.spec.js b/packages/react-native-app-auth/resume-pending-authorize.spec.js index 768e31d23..e81b4a9e7 100644 --- a/packages/react-native-app-auth/resume-pending-authorize.spec.js +++ b/packages/react-native-app-auth/resume-pending-authorize.spec.js @@ -6,6 +6,11 @@ jest.mock('react-native', () => ({ Platform: { OS: 'android' }, })); +jest.mock('react-native-base64', () => ({ + encode: jest.fn(), + decode: jest.fn(), +})); + describe('resumePendingAuthorize', () => { const nativeResume = NativeModules.RNAppAuth.resumePendingAuthorize;