Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions flutter_appauth/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## [Unreleased]

* [Android] Added `androidAllowedBrowsers` property to the `AuthorizationRequest`, `AuthorizationTokenRequest` and `EndSessionRequest` classes to restrict which browser (or Custom Tab implementation) is allowed to handle the request. See the "Restricting the Android browser" section of the README for details
* [Android] Fixed `endSession` throwing an uncaught `ActivityNotFoundException` instead of returning a `no_browser_available` error when no suitable browser is installed

## [13.0.0-dev.1]

* **Breaking change** updated minimum supported SDK version to Flutter 3.44.0/Dart 3.12.0
Expand Down
35 changes: 35 additions & 0 deletions flutter_appauth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,41 @@ Attribute application@name at AndroidManifest.xml:5:9-42 requires a placeholder

If you see this error then update your `build.gradle` to use `+=` instead.

### Restricting the Android browser

By default, AppAuth for Android will use whichever installed browser it considers best, falling back through Chrome Custom Tabs, standalone browsers, Custom Tabs from other browsers etc. The `androidAllowedBrowsers` property on `AuthorizationRequest`, `AuthorizationTokenRequest` and `EndSessionRequest` restricts this to a specific list of browsers, for example to force Chrome

```dart
final AuthorizationTokenResponse result = await appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
'<client_id>',
'<redirect_url>',
discoveryUrl: '<discovery_url>',
androidAllowedBrowsers: [
AndroidBrowser.chromeCustomTab,
AndroidBrowser.chromeBrowser,
],
),
);
```

The `AndroidBrowser` class offers six presets covering Chrome, Firefox and the Samsung Internet browser, each as either a Custom Tab or a standalone browser: `chromeCustomTab`, `chromeBrowser`, `firefoxCustomTab`, `firefoxBrowser`, `samsungCustomTab` and `samsungBrowser`. For a browser that isn't covered by a preset (e.g. one pushed to devices via MDM), use `AndroidBrowser.custom` and provide its package name, signing certificate hash(es) and whether it should be used as a Custom Tab

```dart
androidAllowedBrowsers: [
AndroidBrowser.custom(
packageName: 'com.acme.mdmbrowser',
signatureHashes: {'<signature_hash>'},
useCustomTab: true,
minVersion: '12',
),
],
```

A custom browser's signature hash can be obtained from AppAuth for Android's `BrowserDescriptor.generateSignatureHash`, given the `PackageInfo` of the installed browser APK.

If none of the installed browsers on the device match the allow-list, the request fails the same way it would if no browser were installed at all (a `no_browser_available` error). This property is Android-only; a `null` or empty list means any installed browser may be used, which is the existing behaviour.

### Troubleshooting

#### No Redirect to app after login
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
import net.openid.appauth.AuthorizationResponse;
import net.openid.appauth.AuthorizationService;
import net.openid.appauth.AuthorizationServiceConfiguration;
import net.openid.appauth.browser.AnyBrowserMatcher;
import net.openid.appauth.browser.BrowserAllowList;
import net.openid.appauth.browser.BrowserMatcher;
import net.openid.appauth.browser.VersionRange;
import net.openid.appauth.browser.VersionedBrowserMatcher;
import net.openid.appauth.ClientSecretBasic;
import net.openid.appauth.EndSessionRequest;
import net.openid.appauth.EndSessionResponse;
Expand All @@ -30,7 +35,10 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import io.flutter.embedding.engine.plugins.FlutterPlugin;
import io.flutter.embedding.engine.plugins.activity.ActivityAware;
Expand Down Expand Up @@ -63,6 +71,8 @@ public class FlutterAppauthPlugin
private static final String NULL_ACTIVITY_ERROR_CODE = "null_activity";
private static final String INVALID_CLAIMS_ERROR_CODE = "invalid_claims";
private static final String NO_BROWSER_AVAILABLE_ERROR_CODE = "no_browser_available";
private static final String INVALID_ANDROID_ALLOWED_BROWSER_ERROR_CODE =
"invalid_android_allowed_browser";

private static final String DISCOVERY_ERROR_MESSAGE_FORMAT =
"Error retrieving discovery document: [error: %s, description: %s]";
Expand All @@ -80,6 +90,8 @@ public class FlutterAppauthPlugin
"Failed to authorize: Null activity received";
private static final String NO_BROWSER_AVAILABLE_ERROR_FORMAT =
"Failed to authorize: No suitable browser is available";
private static final String INVALID_ANDROID_ALLOWED_BROWSER_ERROR_FORMAT =
"Unrecognised androidAllowedBrowsers preset: %s";

private final int RC_AUTH_EXCHANGE_CODE = 65030;
private final int RC_AUTH = 65031;
Expand All @@ -90,12 +102,11 @@ public class FlutterAppauthPlugin
private PendingOperation pendingOperation;
private String clientSecret;
private boolean allowInsecureConnections;
private AuthorizationService defaultAuthorizationService;
private AuthorizationService insecureAuthorizationService;
private List<Map<String, Object>> androidAllowedBrowsers;
private final Map<String, AuthorizationService> authorizationServices = new HashMap<>();

private void onAttachedToEngine(Context context, BinaryMessenger binaryMessenger) {
this.applicationContext = context;
createAuthorizationServices();
final MethodChannel channel =
new MethodChannel(binaryMessenger, "crossingthestreams.io/flutter_appauth");
channel.setMethodCallHandler(this);
Expand Down Expand Up @@ -133,25 +144,13 @@ public void onDetachedFromActivity() {
this.mainActivity = null;
}

private void createAuthorizationServices() {
if (defaultAuthorizationService == null) {
defaultAuthorizationService = new AuthorizationService(this.applicationContext);
}

if (insecureAuthorizationService == null) {
AppAuthConfiguration.Builder authConfigBuilder = new AppAuthConfiguration.Builder();
authConfigBuilder.setConnectionBuilder(InsecureConnectionBuilder.INSTANCE);
authConfigBuilder.setSkipIssuerHttpsCheck(true);
insecureAuthorizationService =
new AuthorizationService(applicationContext, authConfigBuilder.build());
}
}

private void disposeAuthorizationServices() {
defaultAuthorizationService.dispose();
insecureAuthorizationService.dispose();
defaultAuthorizationService = null;
insecureAuthorizationService = null;
for (AuthorizationService authorizationService : authorizationServices.values()) {
if (authorizationService != null) {
authorizationService.dispose();
}
}
authorizationServices.clear();
}

private void checkAndSetPendingOperation(String method, Result result) {
Expand Down Expand Up @@ -221,6 +220,8 @@ private AuthorizationTokenRequestParameters processAuthorizationTokenRequestArgu
(Map<String, String>) arguments.get("additionalParameters");
allowInsecureConnections = (boolean) arguments.get("allowInsecureConnections");
final String responseMode = (String) arguments.get("responseMode");
androidAllowedBrowsers =
(List<Map<String, Object>>) arguments.get("androidAllowedBrowsers");

return new AuthorizationTokenRequestParameters(
clientId,
Expand Down Expand Up @@ -266,6 +267,9 @@ private TokenRequestParameters processTokenRequestArguments(Map<String, Object>
final Map<String, String> additionalParameters =
(Map<String, String>) arguments.get("additionalParameters");
allowInsecureConnections = (boolean) arguments.get("allowInsecureConnections");
// A bare token/refresh call never opens a browser, so it must not inherit a stale
// browser list left over from an earlier authorize call.
androidAllowedBrowsers = null;
return new TokenRequestParameters(
clientId,
issuer,
Expand Down Expand Up @@ -294,6 +298,8 @@ private EndSessionRequestParameters processEndSessionRequestArguments(
(Map<String, String>) arguments.get("serviceConfiguration");
final Map<String, String> additionalParameters =
(Map<String, String>) arguments.get("additionalParameters");
androidAllowedBrowsers =
(List<Map<String, Object>>) arguments.get("androidAllowedBrowsers");
return new EndSessionRequestParameters(
idTokenHint,
postLogoutRedirectUrl,
Expand Down Expand Up @@ -581,24 +587,88 @@ private void performEndSessionRequest(

final EndSessionRequest endSessionRequest = endSessionRequestBuilder.build();
AuthorizationService authorizationService = getAuthorizationService();
Intent endSessionIntent = authorizationService.getEndSessionRequestIntent(endSessionRequest);

try {
Intent endSessionIntent = authorizationService.getEndSessionRequestIntent(endSessionRequest);
mainActivity.startActivityForResult(endSessionIntent, RC_END_SESSION);
} catch (ActivityNotFoundException ex) {
finishWithError(NO_BROWSER_AVAILABLE_ERROR_CODE, NO_BROWSER_AVAILABLE_ERROR_FORMAT, ex);
} catch (NullPointerException ex) {
finishWithError(NULL_ACTIVITY_ERROR_CODE, NULL_ACTIVITY_ERROR_FORMAT, ex);
}
}

private AuthorizationService getAuthorizationService() {
// Call to createAuthorizationService() is done as there have been some reported instances where
final String key =
authorizationServiceCacheKey(allowInsecureConnections, androidAllowedBrowsers);
// Services are built on a cache miss as there have been some reported instances where
// the services have been disposed but they're still needed e.g. to refresh tokens
createAuthorizationServices();
AuthorizationService authorizationService =
allowInsecureConnections ? insecureAuthorizationService : defaultAuthorizationService;
AuthorizationService authorizationService = authorizationServices.get(key);
if (authorizationService == null) {
AppAuthConfiguration.Builder authConfigBuilder = new AppAuthConfiguration.Builder();
if (allowInsecureConnections) {
authConfigBuilder.setConnectionBuilder(InsecureConnectionBuilder.INSTANCE);
authConfigBuilder.setSkipIssuerHttpsCheck(true);
}
authConfigBuilder.setBrowserMatcher(buildBrowserMatcher(androidAllowedBrowsers));
authorizationService =
new AuthorizationService(applicationContext, authConfigBuilder.build());
authorizationServices.put(key, authorizationService);
}
return authorizationService;
}

private String authorizationServiceCacheKey(
boolean allowInsecureConnections, List<Map<String, Object>> androidAllowedBrowsers) {
return allowInsecureConnections + "|" + androidAllowedBrowsers;
}

private BrowserMatcher buildBrowserMatcher(List<Map<String, Object>> specs) {
if (specs == null || specs.isEmpty()) {
return AnyBrowserMatcher.INSTANCE;
}
List<BrowserMatcher> matchers = new ArrayList<>();
for (Map<String, Object> spec : specs) {
final String preset = (String) spec.get("preset");
if (preset != null) {
matchers.add(presetBrowserMatcher(preset));
continue;
}
final String packageName = (String) spec.get("packageName");
@SuppressWarnings("unchecked")
final Set<String> hashes = new HashSet<>((List<String>) spec.get("signatureHashes"));
final boolean useCustomTab = Boolean.TRUE.equals(spec.get("useCustomTab"));
final String minVersion = (String) spec.get("minVersion");
final VersionRange range =
minVersion == null ? VersionRange.ANY_VERSION : VersionRange.atLeast(minVersion);
matchers.add(new VersionedBrowserMatcher(packageName, hashes, useCustomTab, range));
}
return new BrowserAllowList(matchers.toArray(new BrowserMatcher[0]));
}

private BrowserMatcher presetBrowserMatcher(String preset) {
switch (preset) {
case "chromeCustomTab":
return VersionedBrowserMatcher.CHROME_CUSTOM_TAB;
case "chromeBrowser":
return VersionedBrowserMatcher.CHROME_BROWSER;
case "firefoxCustomTab":
return VersionedBrowserMatcher.FIREFOX_CUSTOM_TAB;
case "firefoxBrowser":
return VersionedBrowserMatcher.FIREFOX_BROWSER;
case "samsungCustomTab":
return VersionedBrowserMatcher.SAMSUNG_CUSTOM_TAB;
case "samsungBrowser":
return VersionedBrowserMatcher.SAMSUNG_BROWSER;
default:
finishWithError(
INVALID_ANDROID_ALLOWED_BROWSER_ERROR_CODE,
String.format(INVALID_ANDROID_ALLOWED_BROWSER_ERROR_FORMAT, preset),
null);
throw new IllegalArgumentException(
String.format(INVALID_ANDROID_ALLOWED_BROWSER_ERROR_FORMAT, preset));
}
}

private void finishWithTokenError(AuthorizationException ex) {
finishWithError(
TOKEN_ERROR_CODE,
Expand Down
1 change: 1 addition & 0 deletions flutter_appauth/lib/flutter_appauth.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export 'package:flutter_appauth_platform_interface/flutter_appauth_platform_interface.dart'
show
AndroidBrowser,
AuthorizationRequest,
AuthorizationResponse,
AuthorizationServiceConfiguration,
Expand Down
4 changes: 4 additions & 0 deletions flutter_appauth_platform_interface/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## [Unreleased]

* Added `androidAllowedBrowsers` property to the `AuthorizationRequest`, `AuthorizationTokenRequest` and `EndSessionRequest` classes to restrict which Android browser (or Custom Tab implementation) is allowed to handle the request. Accepts a list of `AndroidBrowser` entries, either one of the built-in presets (e.g. `AndroidBrowser.chromeCustomTab`) or a custom browser described via `AndroidBrowser.custom`. This is only applicable to Android

## [13.0.0-dev.1]

* **Breaking change** updated minimum supported SDK version to Flutter 3.44.0/Dart 3.12.0
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export 'src/android_browser.dart';
export 'src/authorization_request.dart';
export 'src/authorization_response.dart';
export 'src/authorization_service_configuration.dart';
Expand Down
93 changes: 93 additions & 0 deletions flutter_appauth_platform_interface/lib/src/android_browser.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/// Describes a browser (or custom tab implementation) that is allowed to
/// handle an authorization or end session request.
///
/// This is only applicable to Android. Use the `chrome*`, `firefox*` and
/// `samsung*` presets to restrict the request to one of the browsers that
/// AppAuth for Android already recognises, or [AndroidBrowser.custom] to
/// describe a browser that isn't covered by a preset e.g. one pushed to
/// devices via MDM.
class AndroidBrowser {
const AndroidBrowser._(this.preset)
: packageName = null,
signatureHashes = null,
useCustomTab = null,
minVersion = null;

/// Describes a custom browser (or custom tab implementation) by its package
/// name and signing certificate.
///
/// [packageName] is the browser's package name, e.g. `com.acme.browser`.
///
/// [signatureHashes] are the browser's Base64-encoded, SHA-512 signing
/// certificate hashes, as returned by AppAuth for Android's
/// `BrowserDescriptor.generateSignatureHash`. A browser matches if it was
/// signed with any of the supplied hashes.
///
/// [useCustomTab] indicates whether the browser should be used as a Chrome
/// Custom Tab (`true`) or as a standalone browser (`false`). Defaults to
/// `true`.
///
/// [minVersion] is the minimum version of the browser that is allowed. When
/// omitted, any version is allowed.
const AndroidBrowser.custom({
required this.packageName,
required this.signatureHashes,
this.useCustomTab = true,
this.minVersion,
}) : preset = null;

/// Chrome, used as a Chrome Custom Tab.
static const AndroidBrowser chromeCustomTab =
AndroidBrowser._('chromeCustomTab');

/// Chrome, used as a standalone browser.
static const AndroidBrowser chromeBrowser = AndroidBrowser._('chromeBrowser');

/// Firefox, used as a Custom Tab.
static const AndroidBrowser firefoxCustomTab =
AndroidBrowser._('firefoxCustomTab');

/// Firefox, used as a standalone browser.
static const AndroidBrowser firefoxBrowser =
AndroidBrowser._('firefoxBrowser');

/// The Samsung Internet browser, used as a Custom Tab.
static const AndroidBrowser samsungCustomTab =
AndroidBrowser._('samsungCustomTab');

/// The Samsung Internet browser, used as a standalone browser.
static const AndroidBrowser samsungBrowser =
AndroidBrowser._('samsungBrowser');

/// The name Android resolves to one of AppAuth for Android's built-in
/// browser matchers, or `null` for a [AndroidBrowser.custom] entry.
final String? preset;

/// The custom browser's package name, or `null` for a preset entry.
final String? packageName;

/// The custom browser's Base64-encoded signing certificate hashes, or
/// `null` for a preset entry.
final Set<String>? signatureHashes;

/// Whether the custom browser should be used as a Custom Tab, or `null` for
/// a preset entry.
final bool? useCustomTab;

/// The minimum allowed version of the custom browser, or `null` for a
/// preset entry or when any version is allowed.
final String? minVersion;

/// Converts this to a map that can be sent over the method channel.
Map<String, Object?> toMap() {
if (preset != null) {
return <String, Object?>{'preset': preset};
}
return <String, Object?>{
'packageName': packageName,
'signatureHashes': signatureHashes?.toList(),
'useCustomTab': useCustomTab,
'minVersion': minVersion,
};
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'android_browser.dart';
import 'external_user_agent.dart';

mixin AuthorizationParameters {
Expand All @@ -14,4 +15,13 @@ mixin AuthorizationParameters {

/// Specifies the response mode to use.
String? responseMode;

/// Restricts which Android browsers (or Custom Tab implementations) are
/// allowed to handle the request.
///
/// This is only applicable to Android. A `null` or empty list means any
/// installed browser may be used, which is the existing behaviour. If none
/// of the installed browsers match, the request fails the same way it
/// would if no browser were installed at all.
List<AndroidBrowser>? androidAllowedBrowsers;
}
Loading