diff --git a/.changeset/appauth-android-request-lifecycle.md b/.changeset/appauth-android-request-lifecycle.md new file mode 100644 index 000000000..dc50f6bfb --- /dev/null +++ b/.changeset/appauth-android-request-lifecycle.md @@ -0,0 +1,5 @@ +--- +"react-native-app-auth": patch +--- + +Snapshot token headers, TLS policy, timeout, parameters, client authentication, PKCE verifier and promise per interactive flow. Keep refresh/registration independent, reject overlapping browser flows without replacing the first, and settle late token failures on their originating promise. Replace the blocking/global prefetch latch with per-issuer asynchronous completion; expose native prefetch completion and errors through the JS promise. diff --git a/docs/docs/usage/config.md b/docs/docs/usage/config.md index f1b9d5a0c..67babe103 100644 --- a/docs/docs/usage/config.md +++ b/docs/docs/usage/config.md @@ -52,3 +52,13 @@ See specific example [configurations for your provider](/docs/category/providers - **androidAllowCustomBrowsers** - (`string[]`) (default: undefined) _ANDROID_ override the used browser for authorization. If no value is provided, all browsers are allowed. - **androidTrustedWebActivity** - (`boolean`) (default: `false`) _ANDROID_ Use [`EXTRA_LAUNCH_AS_TRUSTED_WEB_ACTIVITY`](https://developer.chrome.com/docs/android/trusted-web-activity/) when opening web view. - **connectionTimeoutSeconds** - (`number`) configure the request timeout interval in seconds. This must be a positive number. The default values are 60 seconds on iOS and 15 seconds on Android. + +### Android request isolation + +Custom headers and token-exchange options belong to each call. Pass any required headers on every call; +omitting a header group does not reuse a previous provider's headers. Refresh and registration may run +while authorization is pending without replacing its token-exchange parameters or timeout. + +Only one browser-based authorization or logout can be pending at a time. A second interactive call +rejects with `authentication_in_progress`; finish or cancel the first before retrying. A token exchange +already running after the browser returns keeps its own promise and configuration. diff --git a/docs/docs/usage/prefetch.md b/docs/docs/usage/prefetch.md index e16b8c4f5..b27ad1ed1 100644 --- a/docs/docs/usage/prefetch.md +++ b/docs/docs/usage/prefetch.md @@ -17,5 +17,13 @@ const config = { scopes: [''], }; -prefetchConfiguration(config); +try { + await prefetchConfiguration(config); +} catch (error) { + // Prefetch is optional. authorize() can retry discovery when needed. +} ``` + +The promise resolves only after configuration is available and rejects when discovery fails. +Cached issuers resolve immediately; prefetching a different issuer fetches its own configuration. +Calls on iOS remain a no-op. Handle rejection if you previously called this method without awaiting it. 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..5f0daa1a8 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 @@ -64,27 +64,38 @@ import java.util.List; 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"; private final ReactApplicationContext reactContext; - private Promise promise; - private boolean dangerouslyAllowInsecureHttpRequests; - private Boolean skipCodeExchange; - private Boolean usePKCE; - private Boolean useNonce; - private String codeVerifier; - private String clientAuthMethod = "basic"; - private Map registrationRequestHeaders = null; - private Map authorizationRequestHeaders = null; - private Map tokenRequestHeaders = null; - private Map additionalParametersMap; - private String clientSecret; + private final AtomicReference pendingFlow = new AtomicReference<>(); private final ConcurrentHashMap mServiceConfigurations = new ConcurrentHashMap<>(); - private boolean isPrefetched = false; + + private static final class PendingFlow { + final Promise promise; + final int requestCode; + final AppAuthConfiguration tokenConfiguration; + final Map additionalParameters; + final String clientSecret; + final String clientAuthMethod; + final boolean skipCodeExchange; + String codeVerifier; + + PendingFlow(Promise promise, int requestCode, AppAuthConfiguration tokenConfiguration, + Map additionalParameters, String clientSecret, + String clientAuthMethod, boolean skipCodeExchange) { + this.promise = promise; + this.requestCode = requestCode; + this.tokenConfiguration = tokenConfiguration; + this.additionalParameters = additionalParameters; + this.clientSecret = clientSecret; + this.clientAuthMethod = clientAuthMethod; + this.skipCodeExchange = skipCodeExchange; + } + } public RNAppAuthModule(ReactApplicationContext reactContext) { super(reactContext); @@ -108,50 +119,38 @@ public void prefetchConfiguration( warmChromeCustomTab(reactContext, issuer); } - this.parseHeaderMap(customHeaders); final ConnectionBuilder builder = createConnectionBuilder(dangerouslyAllowInsecureHttpRequests, - this.authorizationRequestHeaders, connectionTimeoutMillis); - final CountDownLatch fetchConfigurationLatch = new CountDownLatch(1); - - if (!isPrefetched) { - if (serviceConfiguration != null && !this.hasServiceConfiguration(issuer)) { - try { - setServiceConfiguration(issuer, createAuthorizationServiceConfiguration(serviceConfiguration)); - isPrefetched = true; - fetchConfigurationLatch.countDown(); - } catch (Exception e) { - promise.reject("configuration_error", "Failed to convert serviceConfiguration", e); - } - } else if (!hasServiceConfiguration(issuer)) { - final Uri issuerUri = Uri.parse(issuer); - AuthorizationServiceConfiguration.fetchFromUrl( - buildConfigurationUriFromIssuer(issuerUri), - new AuthorizationServiceConfiguration.RetrieveConfigurationCallback() { - public void onFetchConfigurationCompleted( - @Nullable AuthorizationServiceConfiguration fetchedConfiguration, - @Nullable AuthorizationException ex) { - if (ex != null) { - promise.reject("service_configuration_fetch_error", "Failed to fetch configuration", - ex); - return; - } - setServiceConfiguration(issuer, fetchedConfiguration); - isPrefetched = true; - fetchConfigurationLatch.countDown(); - } - }, - builder); - } - } else { - fetchConfigurationLatch.countDown(); + getRequestHeaders(customHeaders, "authorize"), connectionTimeoutMillis); + if (hasServiceConfiguration(issuer)) { + promise.resolve(true); + return; } - try { - fetchConfigurationLatch.await(); - promise.resolve(isPrefetched); - } catch (Exception e) { - promise.reject("service_configuration_fetch_error", "Failed to await fetch configuration", e); + if (serviceConfiguration != null) { + try { + setServiceConfiguration(issuer, createAuthorizationServiceConfiguration(serviceConfiguration)); + promise.resolve(true); + } catch (Exception e) { + promise.reject("configuration_error", "Failed to convert serviceConfiguration", e); + } + return; } + + AuthorizationServiceConfiguration.fetchFromUrl( + buildConfigurationUriFromIssuer(Uri.parse(issuer)), + new AuthorizationServiceConfiguration.RetrieveConfigurationCallback() { + public void onFetchConfigurationCompleted( + @Nullable AuthorizationServiceConfiguration fetchedConfiguration, + @Nullable AuthorizationException ex) { + if (ex != null) { + promise.reject("service_configuration_fetch_error", "Failed to fetch configuration", ex); + return; + } + setServiceConfiguration(issuer, fetchedConfiguration); + promise.resolve(true); + } + }, + builder); } @ReactMethod @@ -168,9 +167,8 @@ public void register( final boolean dangerouslyAllowInsecureHttpRequests, final ReadableMap customHeaders, final Promise promise) { - this.parseHeaderMap(customHeaders); final ConnectionBuilder builder = createConnectionBuilder(dangerouslyAllowInsecureHttpRequests, - this.registrationRequestHeaders, connectionTimeoutMillis); + getRequestHeaders(customHeaders, "register"), connectionTimeoutMillis); final AppAuthConfiguration appAuthConfiguration = this.createAppAuthConfiguration(builder, dangerouslyAllowInsecureHttpRequests, null); final HashMap additionalParametersMap = MapUtil.readableMapToHashMap(additionalParameters); @@ -246,22 +244,22 @@ public void authorize( final boolean androidTrustedWebActivity, final boolean androidPrefersEphemeralSession, final Promise promise) { - this.parseHeaderMap(customHeaders); final ConnectionBuilder builder = createConnectionBuilder(dangerouslyAllowInsecureHttpRequests, - this.authorizationRequestHeaders, connectionTimeoutMillis); + getRequestHeaders(customHeaders, "authorize"), connectionTimeoutMillis); final AppAuthConfiguration appAuthConfiguration = this.createAppAuthConfiguration(builder, dangerouslyAllowInsecureHttpRequests, androidAllowCustomBrowsers); 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; - this.clientAuthMethod = clientAuthMethod; - this.skipCodeExchange = skipCodeExchange; - this.useNonce = useNonce; - this.usePKCE = usePKCE; + final AppAuthConfiguration tokenConfiguration = createAppAuthConfiguration( + createConnectionBuilder(dangerouslyAllowInsecureHttpRequests, + getRequestHeaders(customHeaders, "token"), connectionTimeoutMillis), + dangerouslyAllowInsecureHttpRequests, null); + final PendingFlow flow = new PendingFlow(promise, 52, tokenConfiguration, + additionalParametersMap, clientSecret, clientAuthMethod, Boolean.TRUE.equals(skipCodeExchange)); + if (!pendingFlow.compareAndSet(null, flow)) { + promise.reject("authentication_in_progress", "Another authorization or logout is already in progress"); + return; + } // when serviceConfiguration is provided, we don't need to hit up the OpenID // well-known id endpoint @@ -280,10 +278,13 @@ public void authorize( usePKCE, additionalParametersMap, androidTrustedWebActivity, - androidPrefersEphemeralSession); + androidPrefersEphemeralSession, + flow); } catch (ActivityNotFoundException e) { + pendingFlow.compareAndSet(flow, null); promise.reject("browser_not_found", e.getMessage()); } catch (Exception e) { + pendingFlow.compareAndSet(flow, null); promise.reject("authentication_failed", e.getMessage()); } } else { @@ -295,6 +296,7 @@ public void onFetchConfigurationCompleted( @Nullable AuthorizationServiceConfiguration fetchedConfiguration, @Nullable AuthorizationException ex) { if (ex != null) { + pendingFlow.compareAndSet(flow, null); promise.reject("service_configuration_fetch_error", ex.getLocalizedMessage(), ex); return; } @@ -312,10 +314,13 @@ public void onFetchConfigurationCompleted( usePKCE, additionalParametersMap, androidTrustedWebActivity, - androidPrefersEphemeralSession); + androidPrefersEphemeralSession, + flow); } catch (ActivityNotFoundException e) { + pendingFlow.compareAndSet(flow, null); promise.reject("browser_not_found", e.getMessage()); } catch (Exception e) { + pendingFlow.compareAndSet(flow, null); promise.reject("authentication_failed", e.getMessage()); } } @@ -341,9 +346,8 @@ public void refresh( final ReadableMap customHeaders, final ReadableArray androidAllowCustomBrowsers, final Promise promise) { - this.parseHeaderMap(customHeaders); final ConnectionBuilder builder = createConnectionBuilder(dangerouslyAllowInsecureHttpRequests, - this.tokenRequestHeaders, connectionTimeoutMillis); + getRequestHeaders(customHeaders, "token"), connectionTimeoutMillis); final AppAuthConfiguration appAuthConfiguration = createAppAuthConfiguration(builder, dangerouslyAllowInsecureHttpRequests, androidAllowCustomBrowsers); final HashMap additionalParametersMap = MapUtil.readableMapToHashMap(additionalParameters); @@ -352,10 +356,6 @@ public void refresh( additionalParametersMap.put("client_secret", clientSecret); } - // store setting in private field for later use in onActivityResult handler - this.dangerouslyAllowInsecureHttpRequests = dangerouslyAllowInsecureHttpRequests; - this.additionalParametersMap = additionalParametersMap; - // when serviceConfiguration is provided, we don't need to hit up the OpenID // well-known id endpoint if (serviceConfiguration != null || hasServiceConfiguration(issuer)) { @@ -433,7 +433,11 @@ public void logout( dangerouslyAllowInsecureHttpRequests, androidAllowCustomBrowsers); final HashMap additionalParametersMap = MapUtil.readableMapToHashMap(additionalParameters); - this.promise = promise; + final PendingFlow flow = new PendingFlow(promise, 53, null, null, null, null, false); + if (!pendingFlow.compareAndSet(null, flow)) { + promise.reject("authentication_in_progress", "Another authorization or logout is already in progress"); + return; + } if (serviceConfiguration != null || hasServiceConfiguration(issuer)) { try { @@ -447,8 +451,10 @@ public void logout( postLogoutRedirectUri, additionalParametersMap); } catch (ActivityNotFoundException e) { + pendingFlow.compareAndSet(flow, null); promise.reject("browser_not_found", e.getMessage()); } catch (Exception e) { + pendingFlow.compareAndSet(flow, null); promise.reject("end_session_failed", e.getMessage()); } } else { @@ -460,6 +466,7 @@ public void onFetchConfigurationCompleted( @Nullable AuthorizationServiceConfiguration fetchedConfiguration, @Nullable AuthorizationException ex) { if (ex != null) { + pendingFlow.compareAndSet(flow, null); promise.reject("service_configuration_fetch_error", ex.getLocalizedMessage(), ex); return; } @@ -474,8 +481,10 @@ public void onFetchConfigurationCompleted( postLogoutRedirectUri, additionalParametersMap); } catch (ActivityNotFoundException e) { + pendingFlow.compareAndSet(flow, null); promise.reject("browser_not_found", e.getMessage()); } catch (Exception e) { + pendingFlow.compareAndSet(flow, null); promise.reject("end_session_failed", e.getMessage()); } } @@ -489,112 +498,67 @@ public void onFetchConfigurationCompleted( */ @Override public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) { + final PendingFlow flow = pendingFlow.get(); + if (flow == null || flow.requestCode != requestCode || !pendingFlow.compareAndSet(flow, null)) { + return; + } + + final Promise promise = flow.promise; + final String errorCode = requestCode == 52 ? "authentication_error" : "end_session_failed"; try { - if (requestCode == 52) { if (data == null) { - if (promise != null) { - promise.reject("authentication_error", "Data intent is null" ); - } + promise.reject(errorCode, "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); - } + handleAuthorizationException(errorCode, 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); - } + if (requestCode == 53) { + promise.resolve(EndSessionResponseFactory.endSessionResponseToMap(EndSessionResponse.fromIntent(data))); 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 AuthorizationResponse response = AuthorizationResponse.fromIntent(data); + if (response == null) { + promise.reject(errorCode, "Authorization response is missing"); + return; + } + if (flow.skipCodeExchange) { + promise.resolve(flow.codeVerifier != null + ? TokenResponseFactory.authorizationCodeResponseToMap(response, flow.codeVerifier) + : TokenResponseFactory.authorizationResponseToMap(response)); + return; } - AuthorizationService.TokenResponseCallback tokenResponseCallback = new AuthorizationService.TokenResponseCallback() { - + AuthorizationService authService = new AuthorizationService(this.reactContext, flow.tokenConfiguration); + TokenRequest tokenRequest = flow.additionalParameters == null + ? response.createTokenExchangeRequest() + : response.createTokenExchangeRequest(flow.additionalParameters); + AuthorizationService.TokenResponseCallback callback = new AuthorizationService.TokenResponseCallback() { @Override - public void onTokenRequestCompleted( - TokenResponse resp, AuthorizationException ex) { + public void onTokenRequestCompleted(TokenResponse resp, AuthorizationException ex) { if (resp != null) { - WritableMap map = TokenResponseFactory.tokenResponseToMap(resp, response); - if (authorizePromise != null) { - authorizePromise.resolve(map); - } + promise.resolve(TokenResponseFactory.tokenResponseToMap(resp, response)); } else { - if (promise != null) { - handleAuthorizationException("token_exchange_failed", ex, promise); - } + handleAuthorizationException("token_exchange_failed", ex, promise); } } }; - if (this.clientSecret != null) { - ClientAuthentication clientAuth = this.getClientAuthentication(this.clientSecret, this.clientAuthMethod); - authService.performTokenRequest(tokenRequest, clientAuth, tokenResponseCallback); - + if (flow.clientSecret != null) { + authService.performTokenRequest(tokenRequest, + getClientAuthentication(flow.clientSecret, flow.clientAuthMethod), callback); } else { - authService.performTokenRequest(tokenRequest, tokenResponseCallback); - } - - } // close if - - if (requestCode == 53) { - if (data == null) { - if (promise != null) { - promise.reject("end_session_failed", "Data intent is null" ); - } - return; + authService.performTokenRequest(tokenRequest, callback); } - EndSessionResponse response = EndSessionResponse.fromIntent(data); - AuthorizationException ex = AuthorizationException.fromIntent(data); - if (ex != null) { - if (promise != null) { - handleAuthorizationException("end_session_failed", ex, promise); - } - return; - } - final Promise endSessionPromise = this.promise; - if (endSessionPromise != null) { - WritableMap map = EndSessionResponseFactory.endSessionResponseToMap(response); - endSessionPromise.resolve(map); - } - } - } catch (Exception e) { - if(promise != null) { + } catch (Exception e) { promise.reject("run_time_exception", e.getMessage()); - } else { - throw e; } } - } /* * Perform dynamic client registration with the provided configuration @@ -665,7 +629,8 @@ private void authorizeWithConfiguration( final Boolean usePKCE, final Map additionalParametersMap, final Boolean androidTrustedWebActivity, - final Boolean androidPrefersEphemeralSession) { + final Boolean androidPrefersEphemeralSession, + final PendingFlow flow) { String scopesString = null; @@ -726,8 +691,8 @@ private void authorizeWithConfiguration( if (!usePKCE) { authRequestBuilder.setCodeVerifier(null); } else { - this.codeVerifier = CodeVerifierUtil.generateRandomCodeVerifier(); - authRequestBuilder.setCodeVerifier(this.codeVerifier); + flow.codeVerifier = CodeVerifierUtil.generateRandomCodeVerifier(); + authRequestBuilder.setCodeVerifier(flow.codeVerifier); } if (!useNonce) { @@ -858,20 +823,12 @@ private void endSessionWithConfiguration( } } - private void parseHeaderMap(ReadableMap headerMap) { - if (headerMap == null) { - return; - } - if (headerMap.hasKey("register") && headerMap.getType("register") == ReadableType.Map) { - this.registrationRequestHeaders = MapUtil.readableMapToHashMap(headerMap.getMap("register")); - } - if (headerMap.hasKey("authorize") && headerMap.getType("authorize") == ReadableType.Map) { - this.authorizationRequestHeaders = MapUtil.readableMapToHashMap(headerMap.getMap("authorize")); - } - if (headerMap.hasKey("token") && headerMap.getType("token") == ReadableType.Map) { - this.tokenRequestHeaders = MapUtil.readableMapToHashMap(headerMap.getMap("token")); + private Map getRequestHeaders(ReadableMap headerMap, String requestType) { + if (headerMap == null || !headerMap.hasKey(requestType) + || headerMap.getType(requestType) != ReadableType.Map) { + return null; } - + return MapUtil.readableMapToHashMap(headerMap.getMap(requestType)); } private ClientAuthentication getClientAuthentication(String clientSecret, String clientAuthMethod) { diff --git a/packages/react-native-app-auth/index.d.ts b/packages/react-native-app-auth/index.d.ts index 7ff7ec62b..77e3b207b 100644 --- a/packages/react-native-app-auth/index.d.ts +++ b/packages/react-native-app-auth/index.d.ts @@ -182,6 +182,8 @@ type OAuthTokenErrorCode = // https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationError type OICRegistrationErrorCode = 'invalid_redirect_uri' | 'invalid_client_metadata'; type AppAuthErrorCode = + | 'configuration_error' + | 'authentication_in_progress' | 'service_configuration_fetch_error' | 'authentication_failed' | 'token_refresh_failed' diff --git a/packages/react-native-app-auth/index.js b/packages/react-native-app-auth/index.js index a49c3c58c..9c28c070f 100644 --- a/packages/react-native-app-auth/index.js +++ b/packages/react-native-app-auth/index.js @@ -140,7 +140,7 @@ export const prefetchConfiguration = async ({ convertTimeoutForPlatform(Platform.OS, connectionTimeoutSeconds), ]; - RNAppAuth.prefetchConfiguration(...nativeMethodArguments); + await wrapNativeAuthPromise(RNAppAuth.prefetchConfiguration(...nativeMethodArguments)); } };