diff --git a/flutter_appauth/CHANGELOG.md b/flutter_appauth/CHANGELOG.md index cfd82d6d..5f4391a5 100644 --- a/flutter_appauth/CHANGELOG.md +++ b/flutter_appauth/CHANGELOG.md @@ -1,3 +1,7 @@ +## [12.1.0] + +* [iOS] Added support for `https` redirect URIs on iOS 17.4 and newer. When the redirect URL uses the `https` scheme, the plugin now starts the `ASWebAuthenticationSession` using the `callbackWithHTTPSHost:path:` API. This applies to both the default and ephemeral `ASWebAuthenticationSession` external user agents. No Dart API changes are required — pass an `https` redirect URL as usual. Note that the app must declare the matching Associated Domains entitlement (e.g. `webcredentials:`) for the callback to be delivered. + ## [12.0.2] * [iOS][macOS] improved SPM (Swift Package Manager) compatibility. Thanks to PR from [JarvanMo](https://github.com/JarvanMo) diff --git a/flutter_appauth/README.md b/flutter_appauth/README.md index ad6b5e5d..1b7f4dac 100644 --- a/flutter_appauth/README.md +++ b/flutter_appauth/README.md @@ -13,6 +13,7 @@ - [Ephemeral Sessions (iOS and macOS only)](#ephemeral-sessions-ios-and-macos-only) - [Android setup](#android-setup) - [iOS/macOS setup](#iosmacos-setup) + - [Using https redirect URIs on iOS 17.4+](#using-https-redirect-uris-on-ios-174) - [API docs](#api-docs) - [FAQs](#faqs) @@ -260,6 +261,52 @@ Go to the `Info.plist` for your iOS/macOS app to specify the custom scheme so th Note: iOS apps generate a file called `cache.db` which contains the table `cfurl_cache_receiver_data`. This table will contain the access token obtained after the login is completed. If the potential data leak represents a threat for your application then you can disable the information caching for the entire iOS app (ex. https://kunalgupta1508.medium.com/data-leakage-with-cache-db-2d311582cf23). +### Using https redirect URIs on iOS 17.4+ + +Starting with iOS 17.4, you can use an `https` redirect URI (a universal-link-style URL such as `https://app.example.com/callback`) instead of a custom scheme. When the redirect URL you pass uses the `https` scheme, the plugin automatically starts the `ASWebAuthenticationSession` using Apple's `callbackWithHTTPSHost:path:` API. No Dart API change is required — you just pass an `https` redirect URL. + +This only applies to iOS 17.4 and newer. The plugin's minimum deployment target is unchanged, and the feature is gated behind a runtime `@available(iOS 17.4, *)` check so older devices are unaffected. However, the API genuinely does not exist before 17.4, so on earlier versions an `https` redirect cannot work. If you need to support devices below 17.4, branch on the OS version and fall back to a custom scheme: + +```dart +import 'dart:io' show Platform; +import 'package:device_info_plus/device_info_plus.dart'; + +Future redirectUrl() async { + if (Platform.isIOS) { + final info = await DeviceInfoPlugin().iosInfo; + final parts = info.systemVersion.split('.'); + final major = int.tryParse(parts.elementAt(0)) ?? 0; + final minor = parts.length > 1 ? int.tryParse(parts[1]) ?? 0 : 0; + final supportsHttps = major > 17 || (major == 17 && minor >= 4); + if (supportsHttps) { + return 'https://app.example.com/oauth/callback'; + } + } + return 'com.example.app://oauth/callback'; +} +``` + +If your app's minimum iOS version is already 17.4 or higher, you can drop the branching and always use the `https` redirect URL. + +Requirements when using an `https` redirect URI: + +1. **Associated Domains entitlement.** Add a `webcredentials` entry (not `applinks`) for the callback host. `ASWebAuthenticationSession`'s `https` callback uses the `webcredentials` association to verify your app owns the domain — it does not go through the universal-link (`applinks`) machinery, and iOS refuses to start the session without it (validation is lenient on the Simulator, so always test on a real device): + + ```xml + com.apple.developer.associated-domains + + webcredentials:app.example.com + + ``` + +2. **`apple-app-site-association` file** hosted at `https://app.example.com/.well-known/apple-app-site-association` listing your app under the `webcredentials` key: + + ```json + { "webcredentials": { "apps": ["TEAMID.com.example.myapp"] } } + ``` + +3. **Register both redirect URIs with your identity provider** if you support older devices, since OAuth servers match `redirect_uri` exactly. Keep the custom scheme registered in `Info.plist` (see above) for the fallback path. + ## API docs diff --git a/flutter_appauth/ios/flutter_appauth/Sources/flutter_appauth/AppAuthIOSAuthorization.m b/flutter_appauth/ios/flutter_appauth/Sources/flutter_appauth/AppAuthIOSAuthorization.m index 32e8c19b..6054ac44 100644 --- a/flutter_appauth/ios/flutter_appauth/Sources/flutter_appauth/AppAuthIOSAuthorization.m +++ b/flutter_appauth/ios/flutter_appauth/Sources/flutter_appauth/AppAuthIOSAuthorization.m @@ -36,7 +36,8 @@ @implementation AppAuthIOSAuthorization if (exchangeCode) { id agent = [self userAgentWithViewController:rootViewController - externalUserAgent:externalUserAgent]; + externalUserAgent:externalUserAgent + redirectURL:[NSURL URLWithString:redirectUrl]]; return [OIDAuthState authStateByPresentingAuthorizationRequest:request externalUserAgent:agent @@ -68,7 +69,8 @@ @implementation AppAuthIOSAuthorization } else { id agent = [self userAgentWithViewController:rootViewController - externalUserAgent:externalUserAgent]; + externalUserAgent:externalUserAgent + redirectURL:[NSURL URLWithString:redirectUrl]]; return [OIDAuthorizationService presentAuthorizationRequest:request externalUserAgent:agent @@ -136,7 +138,8 @@ @implementation AppAuthIOSAuthorization UIViewController *rootViewController = [self rootViewController]; id externalUserAgent = [self userAgentWithViewController:rootViewController - externalUserAgent:requestParameters.externalUserAgent]; + externalUserAgent:requestParameters.externalUserAgent + redirectURL:postLogoutRedirectURL]; return [OIDAuthorizationService presentEndSessionRequest:endSessionRequest @@ -164,17 +167,22 @@ @implementation AppAuthIOSAuthorization - (id) userAgentWithViewController:(UIViewController *)rootViewController - externalUserAgent:(NSNumber *)externalUserAgent { - if ([externalUserAgent integerValue] == EphemeralASWebAuthenticationSession) { - return [[OIDExternalUserAgentIOSNoSSO alloc] - initWithPresentingViewController:rootViewController]; - } + externalUserAgent:(NSNumber *)externalUserAgent + redirectURL:(NSURL *)redirectURL { if ([externalUserAgent integerValue] == SafariViewController) { return [[OIDExternalUserAgentIOSSafariViewController alloc] initWithPresentingViewController:rootViewController]; } - return [[OIDExternalUserAgentIOS alloc] - initWithPresentingViewController:rootViewController]; + // Both the default (SSO) and ephemeral ASWebAuthenticationSession modes are + // served by OIDExternalUserAgentIOSNoSSO so that `https` redirect URIs are + // supported on iOS 17.4+. AppAuth's own OIDExternalUserAgentIOS only supports + // custom-scheme callbacks. + BOOL prefersEphemeralSession = + [externalUserAgent integerValue] == EphemeralASWebAuthenticationSession; + return [[OIDExternalUserAgentIOSNoSSO alloc] + initWithPresentingViewController:rootViewController + prefersEphemeralSession:prefersEphemeralSession + redirectURL:redirectURL]; } - (UIViewController *)rootViewController { diff --git a/flutter_appauth/ios/flutter_appauth/Sources/flutter_appauth/OIDExternalUserAgentIOSNoSSO.h b/flutter_appauth/ios/flutter_appauth/Sources/flutter_appauth/OIDExternalUserAgentIOSNoSSO.h index be495757..8b3c1cdd 100644 --- a/flutter_appauth/ios/flutter_appauth/Sources/flutter_appauth/OIDExternalUserAgentIOSNoSSO.h +++ b/flutter_appauth/ios/flutter_appauth/Sources/flutter_appauth/OIDExternalUserAgentIOSNoSSO.h @@ -2,8 +2,12 @@ @brief OIDExternalUserAgentIOSNoSSO is a custom user agent based on the default user agent in the AppAuth iOS SDK found here: https://github.com/openid/AppAuth-iOS/blob/master/Source/iOS/OIDExternalUserAgentIOS.h - Ths user agent allows setting `prefersEphemeralSession` flag on iOS - 13 or newer to avoid cookies being shared across the device. + This user agent allows setting the `prefersEphemeralSession` flag on + iOS 13 or newer to avoid cookies being shared across the device. It also + supports `https` redirect URIs on iOS 17.4 or newer by + using `ASWebAuthenticationSession`'s `callbackWithHTTPSHost:path:` API when + the redirect URL passed to the designated initializer uses the `https` + scheme. @copydetails Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -36,13 +40,32 @@ API_UNAVAILABLE(macCatalyst) "This method will not work on iOS 13, use " "initWithPresentingViewController:presentingViewController"); -/*! @brief The designated initializer. +/*! @brief Convenience initializer that prefers an ephemeral session and does + not enable `https` redirect URI handling. @param presentingViewController The view controller from which to present - the - \SFSafariViewController. + the \SFSafariViewController. */ - (nullable instancetype)initWithPresentingViewController: - (UIViewController *)presentingViewController NS_DESIGNATED_INITIALIZER; + (UIViewController *)presentingViewController; + +/*! @brief The designated initializer. + @param presentingViewController The view controller from which to present + the \SFSafariViewController. + @param prefersEphemeralSession Whether the underlying + `ASWebAuthenticationSession` should use a private (ephemeral) browser session + so cookies are not shared across the device. + @param redirectURL The redirect URL of the request. When this uses the + `https` scheme, the `ASWebAuthenticationSession` is started with a + `callbackWithHTTPSHost:path:` callback on iOS 17.4 or newer so that HTTPS + URLs can be used as redirect URIs. May be nil, in which case the legacy + `callbackURLScheme:` behaviour is used. + */ +- (nullable instancetype) + initWithPresentingViewController: + (UIViewController *)presentingViewController + prefersEphemeralSession:(BOOL)prefersEphemeralSession + redirectURL:(nullable NSURL *)redirectURL + NS_DESIGNATED_INITIALIZER; @end diff --git a/flutter_appauth/ios/flutter_appauth/Sources/flutter_appauth/OIDExternalUserAgentIOSNoSSO.m b/flutter_appauth/ios/flutter_appauth/Sources/flutter_appauth/OIDExternalUserAgentIOSNoSSO.m index 1ded133d..c3c8d677 100644 --- a/flutter_appauth/ios/flutter_appauth/Sources/flutter_appauth/OIDExternalUserAgentIOSNoSSO.m +++ b/flutter_appauth/ios/flutter_appauth/Sources/flutter_appauth/OIDExternalUserAgentIOSNoSSO.m @@ -27,6 +27,8 @@ @interface OIDExternalUserAgentIOSNoSSO () @implementation OIDExternalUserAgentIOSNoSSO { UIViewController *_presentingViewController; + BOOL _prefersEphemeralSession; + NSURL *_redirectURL; BOOL _externalUserAgentFlowInProgress; __weak id _session; @@ -47,6 +49,16 @@ - (nullable instancetype)init { - (nullable instancetype)initWithPresentingViewController: (UIViewController *)presentingViewController { + return [self initWithPresentingViewController:presentingViewController + prefersEphemeralSession:YES + redirectURL:nil]; +} + +- (nullable instancetype) + initWithPresentingViewController: + (UIViewController *)presentingViewController + prefersEphemeralSession:(BOOL)prefersEphemeralSession + redirectURL:(nullable NSURL *)redirectURL { self = [super init]; if (self) { #if __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000 @@ -55,6 +67,8 @@ - (nullable instancetype)initWithPresentingViewController: #endif // __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000 _presentingViewController = presentingViewController; + _prefersEphemeralSession = prefersEphemeralSession; + _redirectURL = redirectURL; } return self; } @@ -78,30 +92,64 @@ - (BOOL)presentExternalUserAgentRequest:(id)request // (rdar://40809553) if (!UIAccessibilityIsGuidedAccessEnabled()) { __weak OIDExternalUserAgentIOSNoSSO *weakSelf = self; - NSString *redirectScheme = request.redirectScheme; - ASWebAuthenticationSession *authenticationVC = - [[ASWebAuthenticationSession alloc] + void (^completionHandler)(NSURL *_Nullable, NSError *_Nullable) = ^( + NSURL *_Nullable callbackURL, NSError *_Nullable error) { + __strong OIDExternalUserAgentIOSNoSSO *strongSelf = weakSelf; + if (!strongSelf) { + return; + } + strongSelf->_webAuthenticationVC = nil; + if (callbackURL) { + [strongSelf->_session resumeExternalUserAgentFlowWithURL:callbackURL]; + } else { + NSError *safariError = [OIDErrorUtilities + errorWithCode:OIDErrorCodeUserCanceledAuthorizationFlow + underlyingError:error + description:nil]; + [strongSelf->_session failExternalUserAgentFlowWithError:safariError]; + } + }; + + ASWebAuthenticationSession *authenticationVC = nil; +#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 170400 + // On iOS 17.4 or newer, an `https` redirect URI requires the + // `callbackWithHTTPSHost:path:` API; `callbackURLScheme:` only supports + // custom schemes. + if (@available(iOS 17.4, *)) { + // Only use the https callback for a well-formed https redirect URL with + // a host. Anything else (a custom scheme, or a nil/malformed redirect + // URL such as an end-session request without a postLogoutRedirectUrl) + // must fall through to the callbackURLScheme: path below. Guarding on + // `scheme.length` is essential: `_redirectURL.scheme` is nil when + // `_redirectURL` is nil, and `[nil caseInsensitiveCompare:@"https"]` + // returns NSOrderedSame, which would otherwise pass a nil host/path to + // the nonnull callbackWithHTTPSHost:path: and raise an exception. + if (_redirectURL.scheme.length && + [_redirectURL.scheme caseInsensitiveCompare:@"https"] == + NSOrderedSame && + _redirectURL.host.length) { + // callbackWithHTTPSHost:path: requires a nonnull path; treat a + // host-only redirect URL as the root path. + NSString *redirectPath = + _redirectURL.path.length ? _redirectURL.path : @"/"; + ASWebAuthenticationSessionCallback *callback = + [ASWebAuthenticationSessionCallback + callbackWithHTTPSHost:_redirectURL.host + path:redirectPath]; + authenticationVC = [[ASWebAuthenticationSession alloc] initWithURL:requestURL - callbackURLScheme:redirectScheme - completionHandler:^(NSURL *_Nullable callbackURL, - NSError *_Nullable error) { - __strong OIDExternalUserAgentIOSNoSSO *strongSelf = weakSelf; - if (!strongSelf) { - return; - } - strongSelf->_webAuthenticationVC = nil; - if (callbackURL) { - [strongSelf->_session - resumeExternalUserAgentFlowWithURL:callbackURL]; - } else { - NSError *safariError = [OIDErrorUtilities - errorWithCode:OIDErrorCodeUserCanceledAuthorizationFlow - underlyingError:error - description:nil]; - [strongSelf->_session - failExternalUserAgentFlowWithError:safariError]; - } - }]; + callback:callback + completionHandler:completionHandler]; + } + } +#endif + if (!authenticationVC) { + NSString *redirectScheme = request.redirectScheme; + authenticationVC = + [[ASWebAuthenticationSession alloc] initWithURL:requestURL + callbackURLScheme:redirectScheme + completionHandler:completionHandler]; + } #if __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000 if (@available(iOS 13.0, *)) { authenticationVC.presentationContextProvider = self; @@ -109,7 +157,8 @@ - (BOOL)presentExternalUserAgentRequest:(id)request #endif _webAuthenticationVC = authenticationVC; if (@available(iOS 13.0, *)) { - authenticationVC.prefersEphemeralWebBrowserSession = YES; + authenticationVC.prefersEphemeralWebBrowserSession = + _prefersEphemeralSession; } openedUserAgent = [authenticationVC start]; } diff --git a/flutter_appauth/pubspec.yaml b/flutter_appauth/pubspec.yaml index 1be693c9..e548fba8 100644 --- a/flutter_appauth/pubspec.yaml +++ b/flutter_appauth/pubspec.yaml @@ -2,7 +2,7 @@ name: flutter_appauth description: This plugin provides an abstraction around the Android and iOS AppAuth SDKs so it can be used to communicate with OAuth 2.0 and OpenID Connect providers -version: 12.0.2 +version: 12.1.0 homepage: https://github.com/MaikuB/flutter_appauth/tree/master/flutter_appauth environment: