From 8c4c091b563d8accf0f18b65a735fd4c1a1f5520 Mon Sep 17 00:00:00 2001 From: Brent Kelly <5814579+mrbrentkelly@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:33:04 +0100 Subject: [PATCH 1/5] feat(android): expose allowedBrowsers option for web authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an allowedBrowsers option to NativeAuthorizeOptions that restricts which browsers can handle the web authentication flow on Android. This works around a known issue with Firefox where App Link redirects are not correctly handled, causing login flows to fail. The underlying Auth0.Android SDK supports browser filtering via BrowserPicker and CustomTabsOptions. This change exposes that capability through the React Native bridge. When set, the behaviour is: - Default browser in the list → use it - Default browser not in the list, another allowed browser installed → use that - No allowed browser installed → a0.browser_not_available error This brings parity with the auth0-flutter SDK which already exposes this option (see https://github.com/auth0/auth0-flutter/issues/392). Ref: https://bugzilla.mozilla.org/show_bug.cgi?id=1976809 --- .../java/com/auth0/react/A0Auth0Module.kt | 19 ++++++++++++++-- .../oldarch/com/auth0/react/A0Auth0Spec.kt | 2 ++ .../native/bridge/NativeBridgeManager.ts | 3 ++- src/specs/NativeA0Auth0.ts | 3 ++- src/types/platform-specific.ts | 22 +++++++++++++++++++ 5 files changed, 45 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/com/auth0/react/A0Auth0Module.kt b/android/src/main/java/com/auth0/react/A0Auth0Module.kt index ade908c0..9b0b14d6 100644 --- a/android/src/main/java/com/auth0/react/A0Auth0Module.kt +++ b/android/src/main/java/com/auth0/react/A0Auth0Module.kt @@ -13,12 +13,15 @@ import com.auth0.android.authentication.storage.SecureCredentialsManager import com.auth0.android.authentication.storage.SharedPreferencesStorage import com.auth0.android.dpop.DPoP import com.auth0.android.dpop.DPoPException +import com.auth0.android.provider.BrowserPicker +import com.auth0.android.provider.CustomTabsOptions import com.auth0.android.provider.WebAuthProvider import com.auth0.android.result.Credentials import com.facebook.react.bridge.ActivityEventListener import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.bridge.WritableNativeMap @@ -111,6 +114,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 ephemeralSession: Boolean?, safariViewControllerPresentationStyle: Double?, additionalParameters: ReadableMap?, + allowedBrowserPackages: ReadableArray?, promise: Promise ) { if(this.useDPoP) { @@ -118,7 +122,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 } webAuthPromise = promise val cleanedParameters = mutableMapOf() - + additionalParameters?.let { params -> params.toHashMap().forEach { (key, value) -> value?.let { cleanedParameters[key] = it.toString() } @@ -126,7 +130,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 } val builder = WebAuthProvider.login(auth0!!).withScheme(scheme) - + builder.apply { state?.let { withState(it) } nonce?.let { withNonce(it) } @@ -138,6 +142,17 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 invitationUrl?.let { withInvitationUrl(it) } leeway?.let { if (it.toInt() != 0) withIdTokenVerificationLeeway(it.toInt()) } redirectUri?.let { withRedirectUri(it) } + allowedBrowserPackages?.let { packages -> + val packageList = (0 until packages.size()).mapNotNull { packages.getString(it) } + val browserPicker = BrowserPicker.newBuilder() + .withAllowedPackages(packageList) + .build() + withCustomTabsOptions( + CustomTabsOptions.newBuilder() + .withBrowserPicker(browserPicker) + .build() + ) + } } builder.withParameters(cleanedParameters) diff --git a/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt b/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt index f0c26d10..e798696f 100644 --- a/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt +++ b/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt @@ -5,6 +5,7 @@ import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap abstract class A0Auth0Spec(context: ReactApplicationContext) : ReactContextBaseJavaModule(context) { @@ -83,6 +84,7 @@ abstract class A0Auth0Spec(context: ReactApplicationContext) : ReactContextBaseJ ephemeralSession: Boolean?, safariViewControllerPresentationStyle: Double?, additionalParameters: ReadableMap?, + allowedBrowserPackages: ReadableArray?, promise: Promise ) diff --git a/src/platforms/native/bridge/NativeBridgeManager.ts b/src/platforms/native/bridge/NativeBridgeManager.ts index a5e3a6b7..3ba84197 100644 --- a/src/platforms/native/bridge/NativeBridgeManager.ts +++ b/src/platforms/native/bridge/NativeBridgeManager.ts @@ -104,7 +104,8 @@ export class NativeBridgeManager implements INativeBridge { options.ephemeralSession ?? false, presentationStyle ?? 99, // Since we can't pass null to the native layer, and we need a value to represent this parameter is not set, we are using 99. // //The native layer will check for this and ignore if the value is 99 - parameters.additionalParameters ?? {} + parameters.additionalParameters ?? {}, + options.allowedBrowsers ); return new CredentialsModel(credential); } diff --git a/src/specs/NativeA0Auth0.ts b/src/specs/NativeA0Auth0.ts index 58779f48..fdd55ad9 100644 --- a/src/specs/NativeA0Auth0.ts +++ b/src/specs/NativeA0Auth0.ts @@ -93,7 +93,8 @@ export interface Spec extends TurboModule { leeway: Int32 | undefined, ephemeralSession: boolean | undefined, safariViewControllerPresentationStyle: Int32 | undefined, - additionalParameters: { [key: string]: string } | undefined + additionalParameters: { [key: string]: string } | undefined, + allowedBrowsers: string[] | undefined ): Promise; /** diff --git a/src/types/platform-specific.ts b/src/types/platform-specific.ts index 75dc306b..c1856240 100644 --- a/src/types/platform-specific.ts +++ b/src/types/platform-specific.ts @@ -144,6 +144,28 @@ export interface NativeAuthorizeOptions { presentationStyle?: SafariViewControllerPresentationStyle; } | boolean; + /** + * **Android only:** List of browser package names allowed to handle the web authentication flow. + * When set, only browsers whose package names appear in this list will be used. This is useful + * for excluding browsers that do not correctly handle App Link redirects (e.g. Firefox). + * + * - When the user's default browser is in the list, it is used. + * - When the user's default browser is not in the list but another allowed browser is installed, that browser is used instead. + * - When no allowed browser is installed, an `a0.browser_not_available` error is returned. + * + * @example + * ```typescript + * await authorize({}, { + * allowedBrowsers: [ + * 'com.android.chrome', + * 'com.chrome.beta', + * 'com.microsoft.emmx', // Edge + * 'com.brave.browser', + * ] + * }); + * ``` + */ + allowedBrowsers?: string[]; } /** From 37bfe47c6ad346caf07c2b792ad1b1818efe88da Mon Sep 17 00:00:00 2001 From: Brent Kelly <5814579+mrbrentkelly@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:43:46 +0100 Subject: [PATCH 2/5] test(NativeBridgeManager): update webAuth assertions for allowedBrowserPackages param --- .../native/bridge/__tests__/NativeBridgeManager.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts b/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts index aadec133..a94143d2 100644 --- a/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts +++ b/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts @@ -90,7 +90,8 @@ describe('NativeBridgeManager', () => { options.leeway, options.ephemeralSession, 1, // presentationStyle - parameters.additionalParameters + parameters.additionalParameters, + undefined // allowedBrowserPackages (Android only) ); }); @@ -118,7 +119,8 @@ describe('NativeBridgeManager', () => { 0, // leeway false, // ephemeralSession 99, // presentationStyle - {} // additionalParameters + {}, // additionalParameters + undefined // allowedBrowserPackages (Android only) ); }); From c78f8fa20f88feb7ec8f2bfc781eca717e0cd3da Mon Sep 17 00:00:00 2001 From: Brent Kelly <5814579+mrbrentkelly@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:52:42 +0100 Subject: [PATCH 3/5] fix(ios): add allowedBrowsers param to A0Auth0.mm webAuth method --- ios/A0Auth0.mm | 1 + 1 file changed, 1 insertion(+) diff --git a/ios/A0Auth0.mm b/ios/A0Auth0.mm index 94223abd..31c1261d 100644 --- a/ios/A0Auth0.mm +++ b/ios/A0Auth0.mm @@ -133,6 +133,7 @@ - (dispatch_queue_t)methodQueue ephemeralSession:(nonnull NSNumber *)ephemeralSession safariViewControllerPresentationStyle:(nonnull NSNumber *)safariViewControllerPresentationStyle additionalParameters:(NSDictionary * _Nullable)additionalParameters + allowedBrowsers:(NSArray * _Nullable)allowedBrowsers resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { NSInteger maxAgeValue = maxAge != nil ? (NSInteger)[maxAge doubleValue] : 0; From 2129eb247984deb422d944acc3449ebb8b8110f0 Mon Sep 17 00:00:00 2001 From: Brent Kelly <5814579+mrbrentkelly@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:35:42 +0100 Subject: [PATCH 4/5] feat(android): address PR review feedback - Rename allowedBrowsers -> allowedBrowserPackages across all layers for consistency - Add allowedBrowserPackages support to the logout (clearSession) flow - Add allowedBrowserPackages param to iOS A0Auth0.mm webAuth and webAuthLogout (accepted, ignored on iOS) - Add test cases for allowedBrowserPackages on authorize and clearSession --- .../java/com/auth0/react/A0Auth0Module.kt | 20 +++++-- .../oldarch/com/auth0/react/A0Auth0Spec.kt | 2 +- ios/A0Auth0.mm | 5 +- .../native/bridge/NativeBridgeManager.ts | 5 +- .../__tests__/NativeBridgeManager.spec.ts | 58 ++++++++++++++++++- src/specs/NativeA0Auth0.ts | 5 +- src/types/platform-specific.ts | 10 +++- 7 files changed, 89 insertions(+), 16 deletions(-) diff --git a/android/src/main/java/com/auth0/react/A0Auth0Module.kt b/android/src/main/java/com/auth0/react/A0Auth0Module.kt index 9b0b14d6..74bca84d 100644 --- a/android/src/main/java/com/auth0/react/A0Auth0Module.kt +++ b/android/src/main/java/com/auth0/react/A0Auth0Module.kt @@ -360,15 +360,27 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 override fun getName(): String = NAME @ReactMethod - override fun webAuthLogout(scheme: String, federated: Boolean, redirectUri: String?, promise: Promise) { + override fun webAuthLogout(scheme: String, federated: Boolean, redirectUri: String?, allowedBrowserPackages: ReadableArray?, promise: Promise) { val builder = WebAuthProvider.logout(auth0!!).withScheme(scheme) - + if (federated) { builder.withFederated() } - + redirectUri?.let { builder.withReturnToUrl(it) } - + + allowedBrowserPackages?.let { packages -> + val packageList = (0 until packages.size()).mapNotNull { packages.getString(it) } + val browserPicker = BrowserPicker.newBuilder() + .withAllowedPackages(packageList) + .build() + builder.withCustomTabsOptions( + CustomTabsOptions.newBuilder() + .withBrowserPicker(browserPicker) + .build() + ) + } + builder.start(reactContext.currentActivity as FragmentActivity, object : com.auth0.android.callback.Callback { override fun onSuccess(result: Void?) { diff --git a/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt b/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt index e798696f..713705be 100644 --- a/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt +++ b/android/src/main/oldarch/com/auth0/react/A0Auth0Spec.kt @@ -90,7 +90,7 @@ abstract class A0Auth0Spec(context: ReactApplicationContext) : ReactContextBaseJ @ReactMethod @DoNotStrip - abstract fun webAuthLogout(scheme: String, federated: Boolean, redirectUri: String?, promise: Promise) + abstract fun webAuthLogout(scheme: String, federated: Boolean, redirectUri: String?, allowedBrowserPackages: ReadableArray?, promise: Promise) @ReactMethod @DoNotStrip diff --git a/ios/A0Auth0.mm b/ios/A0Auth0.mm index 31c1261d..6647dcfd 100644 --- a/ios/A0Auth0.mm +++ b/ios/A0Auth0.mm @@ -133,7 +133,7 @@ - (dispatch_queue_t)methodQueue ephemeralSession:(nonnull NSNumber *)ephemeralSession safariViewControllerPresentationStyle:(nonnull NSNumber *)safariViewControllerPresentationStyle additionalParameters:(NSDictionary * _Nullable)additionalParameters - allowedBrowsers:(NSArray * _Nullable)allowedBrowsers + allowedBrowserPackages:(NSArray * _Nullable)allowedBrowserPackages resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { NSInteger maxAgeValue = maxAge != nil ? (NSInteger)[maxAge doubleValue] : 0; @@ -148,8 +148,9 @@ - (dispatch_queue_t)methodQueue RCT_EXPORT_METHOD(webAuthLogout:(NSString *)scheme federated:(BOOL)federated redirectUri:(NSString *)redirectUri + allowedBrowserPackages:(NSArray * _Nullable)allowedBrowserPackages resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject) { + reject:(RCTPromiseRejectBlock)reject) { [self.nativeBridge webAuthLogoutWithScheme:scheme federated:federated redirectUri:redirectUri resolve:resolve reject:reject]; } diff --git a/src/platforms/native/bridge/NativeBridgeManager.ts b/src/platforms/native/bridge/NativeBridgeManager.ts index 3ba84197..c35960bb 100644 --- a/src/platforms/native/bridge/NativeBridgeManager.ts +++ b/src/platforms/native/bridge/NativeBridgeManager.ts @@ -105,7 +105,7 @@ export class NativeBridgeManager implements INativeBridge { presentationStyle ?? 99, // Since we can't pass null to the native layer, and we need a value to represent this parameter is not set, we are using 99. // //The native layer will check for this and ignore if the value is 99 parameters.additionalParameters ?? {}, - options.allowedBrowsers + options.allowedBrowserPackages ); return new CredentialsModel(credential); } @@ -118,7 +118,8 @@ export class NativeBridgeManager implements INativeBridge { Auth0NativeModule.webAuthLogout.bind(Auth0NativeModule), options.customScheme, parameters.federated ?? false, - parameters.returnToUrl + parameters.returnToUrl, + options.allowedBrowserPackages ); } diff --git a/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts b/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts index a94143d2..3f96a4f0 100644 --- a/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts +++ b/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts @@ -91,7 +91,7 @@ describe('NativeBridgeManager', () => { options.ephemeralSession, 1, // presentationStyle parameters.additionalParameters, - undefined // allowedBrowserPackages (Android only) + undefined // allowedBrowserPackages ); }); @@ -120,7 +120,40 @@ describe('NativeBridgeManager', () => { false, // ephemeralSession 99, // presentationStyle {}, // additionalParameters - undefined // allowedBrowserPackages (Android only) + undefined // allowedBrowserPackages + ); + }); + + it('should pass allowedBrowserPackages to native webAuth when provided', async () => { + MockedAuth0NativeModule.webAuth.mockResolvedValueOnce( + nativeSuccessCredentials as any + ); + const allowedBrowserPackages = [ + 'com.android.chrome', + 'com.brave.browser', + ]; + + await bridge.authorize( + { redirectUrl: 'com.myapp://cb' }, + { customScheme: 'com.myapp', allowedBrowserPackages } + ); + + expect(MockedAuth0NativeModule.webAuth).toHaveBeenCalledWith( + 'com.myapp', + 'com.myapp://cb', + undefined, + undefined, + undefined, + undefined, + undefined, + 0, + undefined, + undefined, + 0, + false, + 99, + {}, + allowedBrowserPackages ); }); @@ -151,7 +184,26 @@ describe('NativeBridgeManager', () => { expect(MockedAuth0NativeModule.webAuthLogout).toHaveBeenCalledWith( options.customScheme, parameters.federated, - parameters.returnToUrl + parameters.returnToUrl, + undefined // allowedBrowserPackages + ); + }); + + it('should pass allowedBrowserPackages to native webAuthLogout when provided', async () => { + const parameters = { + federated: false, + returnToUrl: 'com.myapp://logout', + }; + const allowedBrowserPackages = ['com.android.chrome']; + const options = { customScheme: 'com.myapp', allowedBrowserPackages }; + + await bridge.clearSession(parameters, options); + + expect(MockedAuth0NativeModule.webAuthLogout).toHaveBeenCalledWith( + options.customScheme, + parameters.federated, + parameters.returnToUrl, + allowedBrowserPackages ); }); }); diff --git a/src/specs/NativeA0Auth0.ts b/src/specs/NativeA0Auth0.ts index fdd55ad9..d318c3ac 100644 --- a/src/specs/NativeA0Auth0.ts +++ b/src/specs/NativeA0Auth0.ts @@ -94,7 +94,7 @@ export interface Spec extends TurboModule { ephemeralSession: boolean | undefined, safariViewControllerPresentationStyle: Int32 | undefined, additionalParameters: { [key: string]: string } | undefined, - allowedBrowsers: string[] | undefined + allowedBrowserPackages: string[] | undefined ): Promise; /** @@ -103,7 +103,8 @@ export interface Spec extends TurboModule { webAuthLogout( scheme: string, federated: boolean, - redirectUri: string + redirectUri: string, + allowedBrowserPackages: string[] | undefined ): Promise; /** diff --git a/src/types/platform-specific.ts b/src/types/platform-specific.ts index c1856240..0ed266fc 100644 --- a/src/types/platform-specific.ts +++ b/src/types/platform-specific.ts @@ -156,7 +156,7 @@ export interface NativeAuthorizeOptions { * @example * ```typescript * await authorize({}, { - * allowedBrowsers: [ + * allowedBrowserPackages: [ * 'com.android.chrome', * 'com.chrome.beta', * 'com.microsoft.emmx', // Edge @@ -165,7 +165,7 @@ export interface NativeAuthorizeOptions { * }); * ``` */ - allowedBrowsers?: string[]; + allowedBrowserPackages?: string[]; } /** @@ -184,6 +184,12 @@ export interface NativeClearSessionOptions { * See migration guide for details. */ useLegacyCallbackUrl?: boolean; + + /** + * **Android only:** List of browser package names allowed to handle the web logout flow. + * Mirrors the same option on {@link NativeAuthorizeOptions} — see that field for full details. + */ + allowedBrowserPackages?: string[]; } // ========= Web-Specific Options ========= From acc51f3c629c34961ef8c9b178ff144973bb4635 Mon Sep 17 00:00:00 2001 From: Brent Kelly <5814579+mrbrentkelly@users.noreply.github.com> Date: Thu, 7 May 2026 11:03:17 +0100 Subject: [PATCH 5/5] docs: add allowedBrowserPackages example to EXAMPLES.md Co-Authored-By: Claude Sonnet 4.6 --- EXAMPLES.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/EXAMPLES.md b/EXAMPLES.md index deb3ee99..9fdac540 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -63,6 +63,7 @@ - [Android](#android) - [iOS](#ios) - [Expo](#expo) +- [Allowed Browsers (Android)](#allowed-browsers-android) ## Authentication API @@ -1387,6 +1388,66 @@ If you want to support multiple domains, you would have to pass an array of obje You can skip sending the `customScheme` property if you do not want to customize it. +## Allowed Browsers (Android) + +On Android, some browsers do not correctly handle App Link redirects. For example, Firefox renders the callback URL as a web page instead of handing the redirect back to your app, causing the authentication flow to fail silently. + +You can restrict which browsers are allowed to handle the web authentication flow by passing `allowedBrowserPackages` in the options object. When set, only browsers whose package names appear in the list will be used. + +**Behaviour:** +- If the user's default browser is in the list, it is used. +- If the user's default browser is not in the list but another allowed browser is installed, that browser is used instead. +- If no allowed browser is installed, an `a0.browser_not_available` error is returned. + +> **Platform Support:** Android only. This option is ignored on iOS. + +### Using with Hooks + +```typescript +import { useAuth0 } from 'react-native-auth0'; + +const { authorize } = useAuth0(); + +await authorize( + { scope: 'openid profile email' }, + { + allowedBrowserPackages: [ + 'com.android.chrome', + 'com.chrome.beta', + 'com.microsoft.emmx', // Edge + 'com.brave.browser', + 'com.sec.android.app.sbrowser', // Samsung Internet + ], + } +); +``` + +### Using with Auth0 Class + +```typescript +import Auth0 from 'react-native-auth0'; + +const auth0 = new Auth0({ + domain: 'YOUR_AUTH0_DOMAIN', + clientId: 'YOUR_AUTH0_CLIENT_ID', +}); + +await auth0.webAuth.authorize( + { scope: 'openid profile email' }, + { + allowedBrowserPackages: [ + 'com.android.chrome', + 'com.chrome.beta', + 'com.microsoft.emmx', // Edge + 'com.brave.browser', + 'com.sec.android.app.sbrowser', // Samsung Internet + ], + } +); +``` + +The same `allowedBrowserPackages` option is also accepted by `clearSession` to restrict which browser handles the logout flow. + ## DPoP (Demonstrating Proof-of-Possession) [DPoP](https://datatracker.ietf.org/doc/html/rfc9449) (Demonstrating Proof-of-Possession) is an OAuth 2.0 extension that cryptographically binds access and refresh tokens to a client-specific key pair. This prevents token theft and replay attacks by ensuring that even if a token is intercepted, it cannot be used from a different device.