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,