From 69b3d4f1f8f440e94fd882a3b93a776a3d4cc328 Mon Sep 17 00:00:00 2001 From: David Parcerisa Date: Mon, 31 Aug 2026 12:54:47 +0200 Subject: [PATCH] fix(android): let the authorization response survive a task reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the "Data intent is null" failure reported in #1094: start an authorization, leave the app (to read a one-time code from a mail app, for instance), come back from the launcher icon, and the flow dies. Two things combine to cause it. AppAuth keeps the pending request inside AuthorizationManagementActivity, which lives in the caller's task; when the host app's launcher activity is launchMode="singleTask" — what React Native recommends for deep linking — re-entering from the icon clears the task above the root and destroys it, and AppAuth then logs "No stored state - unable to handle response". Separately, the response is delivered with startActivityForResult, which is bound to the living activity instance and cannot cross tasks, so it arrives as a null Intent. This change addresses the delivery half: the response now comes back through a PendingIntent addressed to the launcher activity, completed from onNewIntent (declared but empty until now). The result handling is factored into a shared method so the activity-result path is unchanged. Host apps also need AuthorizationManagementActivity to survive, which they can do by giving it its own taskAffinity and singleInstance without touching their launcher activity's launchMode. Both halves are needed; neither works alone. Measured on an Android 15 emulator. The workaround suggested in #1094 — switching the launcher activity to singleTop — was also tried and is worse: the error disappears but the promise never settles, so the login silently hangs. android:alwaysRetainTaskState="true" has no effect. --- .../java/com/rnappauth/RNAppAuthModule.java | 203 +++++++++++------- 1 file changed, 127 insertions(+), 76 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 f1e955879..e430bfd96 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 @@ -491,80 +491,8 @@ public void onFetchConfigurationCompleted( public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) { try { if (requestCode == 52) { - 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); - } - return; - } - - - final Promise authorizePromise = this.promise; - 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 + handleAuthorizationResult(data); + } if (requestCode == 53) { if (data == null) { @@ -746,9 +674,39 @@ private void authorizeWithConfiguration( customTabsIntent.intent.putExtra(TrustedWebUtils.EXTRA_LAUNCH_AS_TRUSTED_WEB_ACTIVITY, true); } - Intent authIntent = authService.getAuthorizationRequestIntent(authRequest, customTabsIntent); + // Deliver the response through a PendingIntent addressed to the + // launcher activity rather than as an activity result. + // + // startActivityForResult binds the response to the living instance of + // AuthorizationManagementActivity. When the host app's launcher + // activity is launchMode="singleTask" — which React Native recommends + // for deep linking — re-entering the app from the icon clears the task + // above the root and destroys it, and the response then arrives as a + // null Intent: "Data intent is null". See issue #1094. + // + // The Intent carries the component only, with no MAIN/LAUNCHER + // categories, since those are what trigger the task reset. + Intent launchIntent = currentActivity + .getPackageManager() + .getLaunchIntentForPackage(currentActivity.getPackageName()); + Intent completionIntent = new Intent(); + if (launchIntent != null && launchIntent.getComponent() != null) { + completionIntent.setComponent(launchIntent.getComponent()); + } else { + completionIntent.setClass(currentActivity, currentActivity.getClass()); + } + completionIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + + int pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT; + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + // AppAuth writes the response into the Intent, so it must be mutable. + pendingFlags |= PendingIntent.FLAG_MUTABLE; + } + PendingIntent completionPendingIntent = PendingIntent.getActivity( + currentActivity, 52, completionIntent, pendingFlags); - currentActivity.startActivityForResult(authIntent, 52); + authService.performAuthorizationRequest( + authRequest, completionPendingIntent, completionPendingIntent, customTabsIntent); } else { AuthorizationService authService = new AuthorizationService(currentActivity, appAuthConfiguration); PendingIntent pendingIntent = currentActivity.createPendingResult(52, new Intent(), 0); @@ -1115,6 +1073,99 @@ private BrowserMatcher getBrowserAllowList(ReadableArray androidAllowCustomBrows @Override public void onNewIntent(Intent intent) { + if (intent == null || this.promise == null) { + return; + } + if (AuthorizationResponse.fromIntent(intent) != null + || AuthorizationException.fromIntent(intent) != null) { + handleAuthorizationResult(intent); + } + } + + + /* + * Completes an authorization response, whichever way it arrived: as an + * activity result, or as a PendingIntent delivered to the launcher activity. + */ + private void handleAuthorizationResult(Intent data) { + if (data == null) { + if (promise != null) { + promise.reject("authentication_error", "Data intent is null"); + } + return; + } + 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); + } + return; + } + + + final Promise authorizePromise = this.promise; + 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); + } }