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. diff --git a/android/src/main/java/com/auth0/react/A0Auth0Module.kt b/android/src/main/java/com/auth0/react/A0Auth0Module.kt index ade908c0..74bca84d 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) @@ -345,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 f0c26d10..713705be 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,12 +84,13 @@ abstract class A0Auth0Spec(context: ReactApplicationContext) : ReactContextBaseJ ephemeralSession: Boolean?, safariViewControllerPresentationStyle: Double?, additionalParameters: ReadableMap?, + allowedBrowserPackages: ReadableArray?, promise: Promise ) @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 94223abd..6647dcfd 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 + allowedBrowserPackages:(NSArray * _Nullable)allowedBrowserPackages resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { NSInteger maxAgeValue = maxAge != nil ? (NSInteger)[maxAge doubleValue] : 0; @@ -147,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 a5e3a6b7..c35960bb 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.allowedBrowserPackages ); return new CredentialsModel(credential); } @@ -117,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 aadec133..3f96a4f0 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 ); }); @@ -118,7 +119,41 @@ describe('NativeBridgeManager', () => { 0, // leeway false, // ephemeralSession 99, // presentationStyle - {} // additionalParameters + {}, // additionalParameters + 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 ); }); @@ -149,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 58779f48..d318c3ac 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, + allowedBrowserPackages: string[] | undefined ): Promise; /** @@ -102,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 75dc306b..0ed266fc 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({}, { + * allowedBrowserPackages: [ + * 'com.android.chrome', + * 'com.chrome.beta', + * 'com.microsoft.emmx', // Edge + * 'com.brave.browser', + * ] + * }); + * ``` + */ + allowedBrowserPackages?: string[]; } /** @@ -162,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 =========