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/.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/docs/docs/usage/authorization.md b/docs/docs/usage/authorization.md index 506cca414..695ce1bc4 100644 --- a/docs/docs/usage/authorization.md +++ b/docs/docs/usage/authorization.md @@ -35,3 +35,56 @@ 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 android.content.Intent +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` + +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 f1e955879..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 @@ -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; @@ -65,13 +66,34 @@ import java.util.Map; 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 { 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<>(); + 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) { + synchronized (authorizationResultLock) { + if (data == null) { + sStashedAuthorizationResult.set(null); + sConsumedAuthorizationResult.clear(); + } else if (data != sConsumedAuthorizationResult.get()) { + 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; @@ -246,6 +268,12 @@ public void authorize( final boolean androidTrustedWebActivity, final boolean androidPrefersEphemeralSession, final Promise promise) { + 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, this.authorizationRequestHeaders, connectionTimeoutMillis); @@ -254,7 +282,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; @@ -282,8 +309,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 { @@ -295,6 +324,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; } @@ -314,8 +344,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()); } } @@ -484,116 +516,170 @@ public void onFetchConfigurationCompleted( } } - /* - * Called when the OAuth browser activity completes - */ - @Override - public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) { + @ReactMethod + public void resumePendingAuthorize( + final ReadableMap additionalParameters, + final Double connectionTimeoutMillis, + final ReadableMap customHeaders, + final boolean dangerouslyAllowInsecureHttpRequests, + final String clientSecret, + final String clientAuthMethod, + final boolean skipCodeExchange, + final Promise promise) { + 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); + } + } + 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 { - if (requestCode == 52) { if (data == null) { - if (promise != null) { - promise.reject("authentication_error", "Data intent is 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); - AuthorizationException ex = AuthorizationException.fromIntent(data); - if (ex != null) { - if (promise != null) { - handleAuthorizationException("authentication_error", ex, promise); - } + if (response == null) { + resultPromise.reject("authentication_error", "No authorization response in activity result"); 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); - } + if (skipCodeExchange) { + final String verifier = response.request.codeVerifier; + resultPromise.resolve(verifier != null + ? TokenResponseFactory.authorizationCodeResponseToMap(response, verifier) + : TokenResponseFactory.authorizationResponseToMap(response)); 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); + final Map exchangeParameters = new HashMap<>(); + if (additionalParameters != null) { + exchangeParameters.putAll(additionalParameters); } - - AuthorizationService.TokenResponseCallback tokenResponseCallback = new AuthorizationService.TokenResponseCallback() { - + // 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 AuthorizationService.TokenResponseCallback callback = 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); - } + public void onTokenRequestCompleted(final TokenResponse responseToken, final AuthorizationException error) { + authService.dispose(); + if (responseToken != null) { + resultPromise.resolve(TokenResponseFactory.tokenResponseToMap(responseToken, response)); } else { - if (promise != null) { - handleAuthorizationException("token_exchange_failed", ex, promise); - } + handleAuthorizationException("token_exchange_failed", error, resultPromise); } } }; - - if (this.clientSecret != null) { - ClientAuthentication clientAuth = this.getClientAuthentication(this.clientSecret, this.clientAuthMethod); - authService.performTokenRequest(tokenRequest, clientAuth, tokenResponseCallback); - - } else { - authService.performTokenRequest(tokenRequest, tokenResponseCallback); + 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()); + } + } - } // close if + @Override + public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) { + if (requestCode == AUTHORIZATION_REQUEST_CODE) { + 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; + } + final AppAuthConfiguration configuration = createAppAuthConfiguration( + createConnectionBuilder(allowInsecureConnections, tokenHeaders), + allowInsecureConnections, null); + completeAuthorization(data, exchangeSkipped, secret, + authenticationMethod, exchangeParameters, 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; - } - } } /* @@ -737,21 +823,66 @@ 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 = pendingAuthorizePromise.get(); - 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); + + try { + 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); + + 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); + 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..b7171167d 100644 --- a/packages/react-native-app-auth/index.d.ts +++ b/packages/react-native-app-auth/index.d.ts @@ -148,6 +148,20 @@ export function register(config: RegistrationConfiguration): Promise; +export type ResumePendingAuthorizeConfiguration = { + additionalParameters?: { [name: string]: string }; + clientSecret?: string; + clientAuthMethod?: 'basic' | 'post'; + skipCodeExchange?: boolean; + 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..2fa2604f8 100644 --- a/packages/react-native-app-auth/index.js +++ b/packages/react-native-app-auth/index.js @@ -274,6 +274,36 @@ export const authorize = ({ return wrapNativeAuthPromise(RNAppAuth.authorize(...nativeMethodArguments)); }; +export const resumePendingAuthorize = ({ + additionalParameters, + clientSecret, + clientAuthMethod = 'basic', + skipCodeExchange = false, + dangerouslyAllowInsecureHttpRequests = false, + customHeaders, + connectionTimeoutSeconds, +} = {}) => { + const platform = Platform.OS; + if (platform !== 'android') { + return Promise.resolve(null); + } + + validateHeaders(customHeaders); + validateConnectionTimeoutSeconds(connectionTimeoutSeconds); + + return wrapNativeAuthPromise( + RNAppAuth.resumePendingAuthorize( + additionalParameters, + convertTimeoutForPlatform(platform, connectionTimeoutSeconds), + customHeaders, + dangerouslyAllowInsecureHttpRequests, + clientSecret, + clientAuthMethod, + skipCodeExchange + ) + ); +}; + export const refresh = ( { issuer, 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..e81b4a9e7 --- /dev/null +++ b/packages/react-native-app-auth/resume-pending-authorize.spec.js @@ -0,0 +1,68 @@ +import { NativeModules, Platform } from 'react-native'; +import { resumePendingAuthorize } from './'; + +jest.mock('react-native', () => ({ + NativeModules: { RNAppAuth: { resumePendingAuthorize: jest.fn() } }, + Platform: { OS: 'android' }, +})); + +jest.mock('react-native-base64', () => ({ + encode: jest.fn(), + decode: jest.fn(), +})); + +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', + }); + }); +});