diff --git a/AppAuth.xcodeproj/project.pbxproj b/AppAuth.xcodeproj/project.pbxproj index 64496e053..6caaf275b 100644 --- a/AppAuth.xcodeproj/project.pbxproj +++ b/AppAuth.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ 2D81121124C103F300984DA7 /* OIDTokenUtilitiesTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A5EEF1FD20CF07760044F470 /* OIDTokenUtilitiesTests.m */; }; 2D81121224C103F300984DA7 /* OIDURLQueryComponentTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 341742121C5D82D3000EF209 /* OIDURLQueryComponentTests.m */; }; 2D81121324C103F300984DA7 /* OIDURLQueryComponentTestsIOS7.m in Sources */ = {isa = PBXBuildFile; fileRef = 341742131C5D82D3000EF209 /* OIDURLQueryComponentTestsIOS7.m */; }; + 0A981C0100000002000A0981 /* OIDExternalUserAgentIOSCustomBrowserTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A981C0100000001000A0981 /* OIDExternalUserAgentIOSCustomBrowserTests.m */; }; 2D81121424C103F300984DA7 /* OIDRegistrationRequestTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 60140F821DE43BAF00DA0DC3 /* OIDRegistrationRequestTests.m */; }; 2D81121524C103F300984DA7 /* OIDRegistrationResponseTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 60140F851DE43CC700DA0DC3 /* OIDRegistrationResponseTests.m */; }; 2D81121624C103F300984DA7 /* OIDRPProfileCode.m in Sources */ = {isa = PBXBuildFile; fileRef = 34A6638A1E8865090060B664 /* OIDRPProfileCode.m */; }; @@ -170,6 +171,7 @@ 341742201C5D82D3000EF209 /* OIDTokenResponseTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 341742101C5D82D3000EF209 /* OIDTokenResponseTests.m */; }; 341742211C5D82D3000EF209 /* OIDURLQueryComponentTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 341742121C5D82D3000EF209 /* OIDURLQueryComponentTests.m */; }; 341742221C5D82D3000EF209 /* OIDURLQueryComponentTestsIOS7.m in Sources */ = {isa = PBXBuildFile; fileRef = 341742131C5D82D3000EF209 /* OIDURLQueryComponentTestsIOS7.m */; }; + 0A981C0100000003000A0981 /* OIDExternalUserAgentIOSCustomBrowserTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A981C0100000001000A0981 /* OIDExternalUserAgentIOSCustomBrowserTests.m */; }; 3417422B1C5D8502000EF209 /* SafariServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3417422A1C5D8502000EF209 /* SafariServices.framework */; }; 3417422D1C5D850C000EF209 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3417422C1C5D850C000EF209 /* Security.framework */; }; 341AA4D31E7F392C00FCA5C6 /* libAppAuth-macOS.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 340DAE4E1D58216A00EC285B /* libAppAuth-macOS.a */; }; @@ -269,6 +271,7 @@ 343AAA7C1E8346B400F9D36E /* OIDTokenResponseTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 341742101C5D82D3000EF209 /* OIDTokenResponseTests.m */; }; 343AAA7D1E8346B400F9D36E /* OIDURLQueryComponentTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 341742121C5D82D3000EF209 /* OIDURLQueryComponentTests.m */; }; 343AAA7E1E8346B400F9D36E /* OIDURLQueryComponentTestsIOS7.m in Sources */ = {isa = PBXBuildFile; fileRef = 341742131C5D82D3000EF209 /* OIDURLQueryComponentTestsIOS7.m */; }; + 0A981C0100000004000A0981 /* OIDExternalUserAgentIOSCustomBrowserTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A981C0100000001000A0981 /* OIDExternalUserAgentIOSCustomBrowserTests.m */; }; 343AAA7F1E8346B400F9D36E /* OIDRegistrationRequestTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 60140F821DE43BAF00DA0DC3 /* OIDRegistrationRequestTests.m */; }; 343AAA801E8346B400F9D36E /* OIDRegistrationResponseTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 60140F851DE43CC700DA0DC3 /* OIDRegistrationResponseTests.m */; }; 343AAA811E83477100F9D36E /* OIDURLQueryComponent.m in Sources */ = {isa = PBXBuildFile; fileRef = 341741D81C5D8243000EF209 /* OIDURLQueryComponent.m */; }; @@ -450,6 +453,7 @@ 343AAB881E8349CE00F9D36E /* OIDTokenResponseTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 341742101C5D82D3000EF209 /* OIDTokenResponseTests.m */; }; 343AAB891E8349CE00F9D36E /* OIDURLQueryComponentTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 341742121C5D82D3000EF209 /* OIDURLQueryComponentTests.m */; }; 343AAB8A1E8349CE00F9D36E /* OIDURLQueryComponentTestsIOS7.m in Sources */ = {isa = PBXBuildFile; fileRef = 341742131C5D82D3000EF209 /* OIDURLQueryComponentTestsIOS7.m */; }; + 0A981C0100000005000A0981 /* OIDExternalUserAgentIOSCustomBrowserTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A981C0100000001000A0981 /* OIDExternalUserAgentIOSCustomBrowserTests.m */; }; 343AAB8B1E8349CE00F9D36E /* OIDRegistrationRequestTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 60140F821DE43BAF00DA0DC3 /* OIDRegistrationRequestTests.m */; }; 343AAB8C1E8349CE00F9D36E /* OIDRegistrationResponseTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 60140F851DE43CC700DA0DC3 /* OIDRegistrationResponseTests.m */; }; 343AAB8D1E8349CF00F9D36E /* OIDAuthorizationRequestTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 341742011C5D82D3000EF209 /* OIDAuthorizationRequestTests.m */; }; @@ -464,6 +468,7 @@ 343AAB961E8349CF00F9D36E /* OIDTokenResponseTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 341742101C5D82D3000EF209 /* OIDTokenResponseTests.m */; }; 343AAB971E8349CF00F9D36E /* OIDURLQueryComponentTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 341742121C5D82D3000EF209 /* OIDURLQueryComponentTests.m */; }; 343AAB981E8349CF00F9D36E /* OIDURLQueryComponentTestsIOS7.m in Sources */ = {isa = PBXBuildFile; fileRef = 341742131C5D82D3000EF209 /* OIDURLQueryComponentTestsIOS7.m */; }; + 0A981C0100000006000A0981 /* OIDExternalUserAgentIOSCustomBrowserTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A981C0100000001000A0981 /* OIDExternalUserAgentIOSCustomBrowserTests.m */; }; 343AAB991E8349CF00F9D36E /* OIDRegistrationRequestTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 60140F821DE43BAF00DA0DC3 /* OIDRegistrationRequestTests.m */; }; 343AAB9A1E8349CF00F9D36E /* OIDRegistrationResponseTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 60140F851DE43CC700DA0DC3 /* OIDRegistrationResponseTests.m */; }; 343AAB9B1E834A8800F9D36E /* AppAuth.h in Headers */ = {isa = PBXBuildFile; fileRef = 343AAA4D1E8345B600F9D36E /* AppAuth.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -494,6 +499,7 @@ 348970822177B3B000ABEED4 /* OIDURLQueryComponentTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 341742121C5D82D3000EF209 /* OIDURLQueryComponentTests.m */; }; 348970832177B3B000ABEED4 /* OIDServiceConfigurationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3417420A1C5D82D3000EF209 /* OIDServiceConfigurationTests.m */; }; 348970842177B3B000ABEED4 /* OIDURLQueryComponentTestsIOS7.m in Sources */ = {isa = PBXBuildFile; fileRef = 341742131C5D82D3000EF209 /* OIDURLQueryComponentTestsIOS7.m */; }; + 0A981C0100000007000A0981 /* OIDExternalUserAgentIOSCustomBrowserTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A981C0100000001000A0981 /* OIDExternalUserAgentIOSCustomBrowserTests.m */; }; 348970852177B3B000ABEED4 /* OIDRegistrationResponseTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 60140F851DE43CC700DA0DC3 /* OIDRegistrationResponseTests.m */; }; 348970862177B3B000ABEED4 /* OIDServiceDiscoveryTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3417420C1C5D82D3000EF209 /* OIDServiceDiscoveryTests.m */; }; 348970872177B3B000ABEED4 /* OIDAuthStateTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 341742051C5D82D3000EF209 /* OIDAuthStateTests.m */; }; @@ -795,6 +801,7 @@ 341742111C5D82D3000EF209 /* OIDURLQueryComponentTests.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OIDURLQueryComponentTests.h; sourceTree = ""; }; 341742121C5D82D3000EF209 /* OIDURLQueryComponentTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OIDURLQueryComponentTests.m; sourceTree = ""; }; 341742131C5D82D3000EF209 /* OIDURLQueryComponentTestsIOS7.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OIDURLQueryComponentTestsIOS7.m; sourceTree = ""; }; + 0A981C0100000001000A0981 /* OIDExternalUserAgentIOSCustomBrowserTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OIDExternalUserAgentIOSCustomBrowserTests.m; sourceTree = ""; }; 341742231C5D8317000EF209 /* UnitTestsInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = UnitTestsInfo.plist; sourceTree = ""; }; 3417422A1C5D8502000EF209 /* SafariServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SafariServices.framework; path = System/Library/Frameworks/SafariServices.framework; sourceTree = SDKROOT; }; 3417422C1C5D850C000EF209 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; @@ -1139,6 +1146,7 @@ 341742111C5D82D3000EF209 /* OIDURLQueryComponentTests.h */, 341742121C5D82D3000EF209 /* OIDURLQueryComponentTests.m */, 341742131C5D82D3000EF209 /* OIDURLQueryComponentTestsIOS7.m */, + 0A981C0100000001000A0981 /* OIDExternalUserAgentIOSCustomBrowserTests.m */, 60140F821DE43BAF00DA0DC3 /* OIDRegistrationRequestTests.m */, 60140F841DE43C8C00DA0DC3 /* OIDRegistrationResponseTests.h */, 60140F851DE43CC700DA0DC3 /* OIDRegistrationResponseTests.m */, @@ -2079,6 +2087,7 @@ 2DEB066224CF5CFB00DF47E7 /* OIDTVTokenRequestTests.m in Sources */, 2D81120D24C103F300984DA7 /* OIDServiceConfigurationTests.m in Sources */, 2D81121324C103F300984DA7 /* OIDURLQueryComponentTestsIOS7.m in Sources */, + 0A981C0100000002000A0981 /* OIDExternalUserAgentIOSCustomBrowserTests.m in Sources */, 2D81121524C103F300984DA7 /* OIDRegistrationResponseTests.m in Sources */, 2D81120824C103F200984DA7 /* OIDAuthStateTests.m in Sources */, 2D81121024C103F300984DA7 /* OIDTokenResponseTests.m in Sources */, @@ -2210,6 +2219,7 @@ 341742211C5D82D3000EF209 /* OIDURLQueryComponentTests.m in Sources */, 341742201C5D82D3000EF209 /* OIDTokenResponseTests.m in Sources */, 341742221C5D82D3000EF209 /* OIDURLQueryComponentTestsIOS7.m in Sources */, + 0A981C0100000003000A0981 /* OIDExternalUserAgentIOSCustomBrowserTests.m in Sources */, E56963F03064815800AC4DFE /* OIDExternalUserAgentIOSTests.m in Sources */, 3417421E1C5D82D3000EF209 /* OIDServiceDiscoveryTests.m in Sources */, 3417421F1C5D82D3000EF209 /* OIDTokenRequestTests.m in Sources */, @@ -2385,6 +2395,7 @@ 343AAA7D1E8346B400F9D36E /* OIDURLQueryComponentTests.m in Sources */, 343AAA791E8346B400F9D36E /* OIDServiceConfigurationTests.m in Sources */, 343AAA7E1E8346B400F9D36E /* OIDURLQueryComponentTestsIOS7.m in Sources */, + 0A981C0100000004000A0981 /* OIDExternalUserAgentIOSCustomBrowserTests.m in Sources */, 343AAA801E8346B400F9D36E /* OIDRegistrationResponseTests.m in Sources */, 343AAA7A1E8346B400F9D36E /* OIDServiceDiscoveryTests.m in Sources */, 343AAA751E8346B400F9D36E /* OIDAuthStateTests.m in Sources */, @@ -2472,6 +2483,7 @@ 343AAB891E8349CE00F9D36E /* OIDURLQueryComponentTests.m in Sources */, 343AAB851E8349CE00F9D36E /* OIDServiceConfigurationTests.m in Sources */, 343AAB8A1E8349CE00F9D36E /* OIDURLQueryComponentTestsIOS7.m in Sources */, + 0A981C0100000005000A0981 /* OIDExternalUserAgentIOSCustomBrowserTests.m in Sources */, 343AAB8C1E8349CE00F9D36E /* OIDRegistrationResponseTests.m in Sources */, 343AAB861E8349CE00F9D36E /* OIDServiceDiscoveryTests.m in Sources */, 343AAB811E8349CE00F9D36E /* OIDAuthStateTests.m in Sources */, @@ -2532,6 +2544,7 @@ 343AAB971E8349CF00F9D36E /* OIDURLQueryComponentTests.m in Sources */, 343AAB931E8349CF00F9D36E /* OIDServiceConfigurationTests.m in Sources */, 343AAB981E8349CF00F9D36E /* OIDURLQueryComponentTestsIOS7.m in Sources */, + 0A981C0100000006000A0981 /* OIDExternalUserAgentIOSCustomBrowserTests.m in Sources */, 343AAB9A1E8349CF00F9D36E /* OIDRegistrationResponseTests.m in Sources */, 343AAB941E8349CF00F9D36E /* OIDServiceDiscoveryTests.m in Sources */, 343AAB8F1E8349CF00F9D36E /* OIDAuthStateTests.m in Sources */, @@ -2587,6 +2600,7 @@ 348970822177B3B000ABEED4 /* OIDURLQueryComponentTests.m in Sources */, 348970832177B3B000ABEED4 /* OIDServiceConfigurationTests.m in Sources */, 348970842177B3B000ABEED4 /* OIDURLQueryComponentTestsIOS7.m in Sources */, + 0A981C0100000007000A0981 /* OIDExternalUserAgentIOSCustomBrowserTests.m in Sources */, 348970852177B3B000ABEED4 /* OIDRegistrationResponseTests.m in Sources */, 348970862177B3B000ABEED4 /* OIDServiceDiscoveryTests.m in Sources */, 348970872177B3B000ABEED4 /* OIDAuthStateTests.m in Sources */, diff --git a/CHANGELOG.md b/CHANGELOG.md index 072dca622..7739b9cc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # UNRELEASED - Improved the state validation error messages to distinguish a missing `state` parameter from a mismatched one. The end session message now notes that a provider's `end_session_endpoint` may point at a legacy or SAML single-logout endpoint, which does not echo `state`. Addresses issue #956. - Fixed `OIDExternalUserAgentIOSCustomBrowser` failing the flow with "Unable to open Safari." when `canOpenURL:` returns `NO` for a URL that the system can open, for example an https URL when Safari is restricted and another browser is the default. The flow now fails only if the URL really cannot be opened. Addresses issue #980. +- Added `OIDExternalUserAgentIOSCustomBrowserURLOpener` and `-[OIDExternalUserAgentIOSCustomBrowser initWithURLTransformation:canOpenURLScheme:appStoreURL:URLOpener:]` so an app can supply the object that opens URLs instead of `UIApplication`. The existing initializers keep using `UIApplication`. - Fixed a crash when the user cancels sign-in on iOS, caused by `ASWebAuthenticationSession` being deallocated during its completion handler or dismiss animation. Based on [#917](https://github.com/openid/AppAuth-iOS/pull/917) by @Prash88. # 3.0.0 diff --git a/Sources/AppAuth/iOS/OIDExternalUserAgentIOSCustomBrowser.h b/Sources/AppAuth/iOS/OIDExternalUserAgentIOSCustomBrowser.h index 2032e8c91..1d22691ce 100644 --- a/Sources/AppAuth/iOS/OIDExternalUserAgentIOSCustomBrowser.h +++ b/Sources/AppAuth/iOS/OIDExternalUserAgentIOSCustomBrowser.h @@ -33,6 +33,26 @@ NS_ASSUME_NONNULL_BEGIN */ typedef NSURL *_Nullable (^OIDCustomBrowserURLTransformation)(NSURL *_Nullable requestURL); +/*! @brief Opens URLs on behalf of @c OIDExternalUserAgentIOSCustomBrowser. + @discussion The default opener forwards to @c UIApplication. Supply your own to open URLs + where @c UIApplication is unavailable, or to substitute the opener in tests. + */ +@protocol OIDExternalUserAgentIOSCustomBrowserURLOpener + +/*! @brief Whether @c URL can be opened. Mirrors @c -[UIApplication canOpenURL:] and is subject + to the same @c LSApplicationQueriesSchemes restrictions. + */ +- (BOOL)canOpenURL:(NSURL *)URL; + +/*! @brief Opens @c URL and reports the outcome on the main queue. Mirrors + @c -[UIApplication openURL:options:completionHandler:]. + @param URL The URL to open. + @param completionHandler Called with YES if the URL was opened, NO otherwise. May be nil. + */ +- (void)openURL:(NSURL *)URL completionHandler:(nullable void (^)(BOOL success))completionHandler; + +@end + /*! @brief An implementation of the OIDExternalUserAgent protocol for iOS that uses a custom browser (i.e. not Safari) for external requests. It is suitable for browsers that offer a custom url scheme that simply replaces the "https" scheme. It is not designed @@ -54,6 +74,10 @@ API_UNAVAILABLE(macCatalyst) */ @property(nonatomic, readonly, nullable) NSURL *appStoreURL; +/*! @brief The object used to open URLs. Defaults to an opener backed by @c UIApplication. + */ +@property(nonatomic, readonly) id URLOpener; + /*! @brief An instance of @c OIDExternalUserAgentIOSCustomBrowser for Chrome. */ + (instancetype)CustomBrowserChrome; @@ -92,6 +116,19 @@ API_UNAVAILABLE(macCatalyst) */ - (nullable instancetype)initWithURLTransformation:(OIDCustomBrowserURLTransformation)URLTransformation; +/*! @brief Convenience initializer. Uses an opener backed by @c UIApplication. + @param URLTransformation the transformation block to translate the URL into one that will open + in the desired custom browser. + @param canOpenURLScheme any scheme supported by the browser used to check if the browser is + installed. + @param appStoreURL URL of the browser in the app store. When this and @c canOpenURLScheme + are non-nil, @c presentExternalUserAgentRequest:session will redirect the user to the app store + if the browser is not installed. + */ +- (nullable instancetype)initWithURLTransformation:(OIDCustomBrowserURLTransformation)URLTransformation + canOpenURLScheme:(nullable NSString *)canOpenURLScheme + appStoreURL:(nullable NSURL *)appStoreURL; + /*! @brief The designated initializer. @param URLTransformation the transformation block to translate the URL into one that will open in the desired custom browser. @@ -100,10 +137,14 @@ API_UNAVAILABLE(macCatalyst) @param appStoreURL URL of the browser in the app store. When this and @c canOpenURLScheme are non-nil, @c presentExternalUserAgentRequest:session will redirect the user to the app store if the browser is not installed. + @param URLOpener the object used to open URLs. Pass nil to use an opener backed by + @c UIApplication. */ - (nullable instancetype)initWithURLTransformation:(OIDCustomBrowserURLTransformation)URLTransformation canOpenURLScheme:(nullable NSString *)canOpenURLScheme appStoreURL:(nullable NSURL *)appStoreURL + URLOpener: + (nullable id)URLOpener NS_DESIGNATED_INITIALIZER; @end diff --git a/Sources/AppAuth/iOS/OIDExternalUserAgentIOSCustomBrowser.m b/Sources/AppAuth/iOS/OIDExternalUserAgentIOSCustomBrowser.m index 784ada352..c2e4a9081 100644 --- a/Sources/AppAuth/iOS/OIDExternalUserAgentIOSCustomBrowser.m +++ b/Sources/AppAuth/iOS/OIDExternalUserAgentIOSCustomBrowser.m @@ -34,6 +34,25 @@ NS_ASSUME_NONNULL_BEGIN +/*! @brief The default @c OIDExternalUserAgentIOSCustomBrowserURLOpener, backed by + @c UIApplication. + */ +@interface OIDExternalUserAgentIOSCustomBrowserApplicationURLOpener + : NSObject +@end + +@implementation OIDExternalUserAgentIOSCustomBrowserApplicationURLOpener + +- (BOOL)canOpenURL:(NSURL *)URL { + return [[UIApplication sharedApplication] canOpenURL:URL]; +} + +- (void)openURL:(NSURL *)URL completionHandler:(nullable void (^)(BOOL))completionHandler { + [[UIApplication sharedApplication] openURL:URL options:@{} completionHandler:completionHandler]; +} + +@end + @implementation OIDExternalUserAgentIOSCustomBrowser + (instancetype)CustomBrowserChrome { @@ -122,11 +141,25 @@ - (nullable instancetype)initWithURLTransformation: initWithURLTransformation:(OIDCustomBrowserURLTransformation)URLTransformation canOpenURLScheme:(nullable NSString *)canOpenURLScheme appStoreURL:(nullable NSURL *)appStoreURL { + return [self initWithURLTransformation:URLTransformation + canOpenURLScheme:canOpenURLScheme + appStoreURL:appStoreURL + URLOpener:nil]; +} + +- (nullable instancetype) + initWithURLTransformation:(OIDCustomBrowserURLTransformation)URLTransformation + canOpenURLScheme:(nullable NSString *)canOpenURLScheme + appStoreURL:(nullable NSURL *)appStoreURL + URLOpener: + (nullable id)URLOpener { self = [super init]; if (self) { _URLTransformation = URLTransformation; _canOpenURLScheme = canOpenURLScheme; _appStoreURL = appStoreURL; + _URLOpener = + URLOpener ?: [[OIDExternalUserAgentIOSCustomBrowserApplicationURLOpener alloc] init]; } return self; } @@ -145,8 +178,8 @@ - (BOOL)presentExternalUserAgentRequest:(nonnull id // Opens AppStore if app isn't installed NSString *testURLString = [NSString stringWithFormat:@"%@://example.com", _canOpenURLScheme]; NSURL *testURL = [NSURL URLWithString:testURLString]; - if (![[UIApplication sharedApplication] canOpenURL:testURL]) { - [[UIApplication sharedApplication] openURL:_appStoreURL options:@{} completionHandler:nil]; + if (![_URLOpener canOpenURL:testURL]) { + [_URLOpener openURL:_appStoreURL completionHandler:nil]; return NO; } } @@ -161,16 +194,15 @@ - (BOOL)presentExternalUserAgentRequest:(nonnull id // LSApplicationQueriesSchemes and can return NO for a URL that // -openURL:options:completionHandler: opens successfully (e.g. an https URL when Safari is // unavailable and another browser is the default). The flow fails only if the open really fails. - [[UIApplication sharedApplication] openURL:requestURL - options:@{} - completionHandler:^(BOOL success) { - if (!success) { - NSError *safariError = [OIDErrorUtilities errorWithCode:OIDErrorCodeSafariOpenError - underlyingError:nil - description:@"Unable to open Safari."]; - [session failExternalUserAgentFlowWithError:safariError]; - } - }]; + [_URLOpener openURL:requestURL + completionHandler:^(BOOL success) { + if (!success) { + NSError *safariError = [OIDErrorUtilities errorWithCode:OIDErrorCodeSafariOpenError + underlyingError:nil + description:@"Unable to open Safari."]; + [session failExternalUserAgentFlowWithError:safariError]; + } + }]; return YES; } diff --git a/UnitTests/OIDExternalUserAgentIOSCustomBrowserTests.m b/UnitTests/OIDExternalUserAgentIOSCustomBrowserTests.m new file mode 100644 index 000000000..e2a432368 --- /dev/null +++ b/UnitTests/OIDExternalUserAgentIOSCustomBrowserTests.m @@ -0,0 +1,284 @@ +/*! @file OIDExternalUserAgentIOSCustomBrowserTests.m + @brief AppAuth iOS SDK + @copyright + Copyright 2026 Google LLC + @copydetails + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST && !SWIFT_PACKAGE + +#import + +#import "OIDAuthorizationRequestTests.h" +#import "Sources/AppAuth/iOS/OIDExternalUserAgentIOSCustomBrowser.h" +#import "Sources/AppAuthCore/OIDAuthorizationRequest.h" +#import "Sources/AppAuthCore/OIDAuthorizationService.h" +#import "Sources/AppAuthCore/OIDError.h" +#import "Sources/AppAuthCore/OIDExternalUserAgent.h" +#import "Sources/AppAuthCore/OIDExternalUserAgentRequest.h" +#import "Sources/AppAuthCore/OIDExternalUserAgentSession.h" + +// Ignore warnings about "Use of GNU statement expression extension" which is raised by our use of +// the XCTAssert___ macros. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wgnu" + +NS_ASSUME_NONNULL_BEGIN + +/*! @brief Fake @c OIDExternalUserAgentIOSCustomBrowserURLOpener that records calls and lets tests + drive the open completion handler. + */ +@interface OIDTestCustomBrowserURLOpener : NSObject + +@property(nonatomic) BOOL canOpenURLResult; +@property(nonatomic, readonly) NSMutableArray *canOpenURLQueries; +@property(nonatomic, readonly) NSMutableArray *openedURLs; +@property(nonatomic, copy, nullable) void (^lastCompletionHandler)(BOOL success); + +@end + +@implementation OIDTestCustomBrowserURLOpener + +- (instancetype)init { + self = [super init]; + if (self) { + _canOpenURLResult = YES; + _canOpenURLQueries = [NSMutableArray array]; + _openedURLs = [NSMutableArray array]; + } + return self; +} + +- (BOOL)canOpenURL:(NSURL *)URL { + [_canOpenURLQueries addObject:URL]; + return _canOpenURLResult; +} + +- (void)openURL:(NSURL *)URL completionHandler:(nullable void (^)(BOOL success))completionHandler { + [_openedURLs addObject:URL]; + _lastCompletionHandler = [completionHandler copy]; +} + +@end + +/*! @brief Fake @c OIDExternalUserAgentSession that records failures reported to it. + */ +@interface OIDTestExternalUserAgentSession : NSObject + +@property(nonatomic, readonly) NSMutableArray *failureErrors; + +@end + +@implementation OIDTestExternalUserAgentSession + +- (instancetype)init { + self = [super init]; + if (self) { + _failureErrors = [NSMutableArray array]; + } + return self; +} + +- (void)cancel { +} + +- (void)cancelWithCompletion:(nullable void (^)(void))completion { + if (completion) { + completion(); + } +} + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-implementations" +- (BOOL)resumeExternalUserAgentFlowWithURL:(NSURL *)URL { + return NO; +} +#pragma clang diagnostic pop + +- (BOOL)resumeExternalUserAgentFlowWithURL:(NSURL *)URL + error:(NSError *_Nullable *_Nullable)error { + return NO; +} + +- (void)failExternalUserAgentFlowWithError:(NSError *)error { + [_failureErrors addObject:error]; +} + +@end + +/*! @brief Fake @c OIDExternalUserAgent that captures the session passed to it. + */ +@interface OIDTestCapturingExternalUserAgent : NSObject + +@property(nonatomic, nullable) id session; + +@end + +@implementation OIDTestCapturingExternalUserAgent + +- (BOOL)presentExternalUserAgentRequest:(id)request + session:(id)session { + _session = session; + return YES; +} + +- (void)dismissExternalUserAgentAnimated:(BOOL)animated completion:(void (^)(void))completion { + completion(); +} + +@end + +/*! @brief Unit tests covering @c presentExternalUserAgentRequest:session: of + @c OIDExternalUserAgentIOSCustomBrowser through an injected URL opener, and the session's + failure idempotency. + */ +@interface OIDExternalUserAgentIOSCustomBrowserTests : XCTestCase +@end + +@implementation OIDExternalUserAgentIOSCustomBrowserTests + +- (OIDExternalUserAgentIOSCustomBrowser *)customBrowserWithOpener: + (id)opener { + OIDCustomBrowserURLTransformation transform = [OIDExternalUserAgentIOSCustomBrowser + URLTransformationSchemeSubstitutionHTTPS:@"testbrowser" + HTTP:@"testbrowser-http"]; + return [[OIDExternalUserAgentIOSCustomBrowser alloc] initWithURLTransformation:transform + canOpenURLScheme:nil + appStoreURL:nil + URLOpener:opener]; +} + +- (void)testPresentOpensTransformedURLAndReturnsYES { + OIDTestCustomBrowserURLOpener *opener = [[OIDTestCustomBrowserURLOpener alloc] init]; + OIDTestExternalUserAgentSession *session = [[OIDTestExternalUserAgentSession alloc] init]; + OIDExternalUserAgentIOSCustomBrowser *browser = [self customBrowserWithOpener:opener]; + + BOOL presented = + [browser presentExternalUserAgentRequest:[OIDAuthorizationRequestTests testInstance] + session:session]; + + XCTAssertTrue(presented); + XCTAssertEqual(opener.openedURLs.count, 1u); + XCTAssertEqualObjects(opener.openedURLs.firstObject.scheme, @"testbrowser"); + XCTAssertEqual(session.failureErrors.count, 0u); + XCTAssertEqual(opener.canOpenURLQueries.count, 0u); +} + +// Pins the fix for issue #980: canOpenURL: can answer NO for a URL that openURL: opens. +- (void)testPresentIgnoresCanOpenURLForRequestURL { + OIDTestCustomBrowserURLOpener *opener = [[OIDTestCustomBrowserURLOpener alloc] init]; + opener.canOpenURLResult = NO; + OIDTestExternalUserAgentSession *session = [[OIDTestExternalUserAgentSession alloc] init]; + OIDExternalUserAgentIOSCustomBrowser *browser = [self customBrowserWithOpener:opener]; + + BOOL presented = + [browser presentExternalUserAgentRequest:[OIDAuthorizationRequestTests testInstance] + session:session]; + + XCTAssertTrue(presented); + XCTAssertEqual(opener.openedURLs.count, 1u); + XCTAssertEqual(session.failureErrors.count, 0u); +} + +- (void)testOpenFailureFailsSessionExactlyOnce { + OIDTestCustomBrowserURLOpener *opener = [[OIDTestCustomBrowserURLOpener alloc] init]; + OIDTestExternalUserAgentSession *session = [[OIDTestExternalUserAgentSession alloc] init]; + OIDExternalUserAgentIOSCustomBrowser *browser = [self customBrowserWithOpener:opener]; + + [browser presentExternalUserAgentRequest:[OIDAuthorizationRequestTests testInstance] + session:session]; + XCTAssertNotNil(opener.lastCompletionHandler); + opener.lastCompletionHandler(NO); + + XCTAssertEqual(session.failureErrors.count, 1u); + XCTAssertEqualObjects(session.failureErrors.firstObject.domain, OIDGeneralErrorDomain); + XCTAssertEqual(session.failureErrors.firstObject.code, OIDErrorCodeSafariOpenError); +} + +- (void)testOpenSuccessDoesNotFailSession { + OIDTestCustomBrowserURLOpener *opener = [[OIDTestCustomBrowserURLOpener alloc] init]; + OIDTestExternalUserAgentSession *session = [[OIDTestExternalUserAgentSession alloc] init]; + OIDExternalUserAgentIOSCustomBrowser *browser = [self customBrowserWithOpener:opener]; + + [browser presentExternalUserAgentRequest:[OIDAuthorizationRequestTests testInstance] + session:session]; + XCTAssertNotNil(opener.lastCompletionHandler); + opener.lastCompletionHandler(YES); + + XCTAssertEqual(session.failureErrors.count, 0u); +} + +- (void)testNilTransformedURLReturnsNOWithoutOpening { + OIDTestCustomBrowserURLOpener *opener = [[OIDTestCustomBrowserURLOpener alloc] init]; + OIDTestExternalUserAgentSession *session = [[OIDTestExternalUserAgentSession alloc] init]; + OIDCustomBrowserURLTransformation nilTransform = ^NSURL *_Nullable(NSURL *_Nullable requestURL) { + return nil; + }; + OIDExternalUserAgentIOSCustomBrowser *browser = + [[OIDExternalUserAgentIOSCustomBrowser alloc] initWithURLTransformation:nilTransform + canOpenURLScheme:nil + appStoreURL:nil + URLOpener:opener]; + + BOOL presented = + [browser presentExternalUserAgentRequest:[OIDAuthorizationRequestTests testInstance] + session:session]; + + XCTAssertFalse(presented); + XCTAssertEqual(opener.openedURLs.count, 0u); + XCTAssertEqual(session.failureErrors.count, 0u); +} + +// OIDExternalUserAgentIOSCustomBrowser fails the session from an asynchronous completion handler, +// so a session that has already finished (cancelled, or failed another way) must ignore a second +// failure. +- (void)testSessionReportsFailureOnlyOnce { + OIDTestCapturingExternalUserAgent *agent = [[OIDTestCapturingExternalUserAgent alloc] init]; + __block NSUInteger callbackCount = 0; + __block NSError *receivedError = nil; + + id session = [OIDAuthorizationService + presentAuthorizationRequest:[OIDAuthorizationRequestTests testInstance] + externalUserAgent:agent + callback:^(OIDAuthorizationResponse *_Nullable response, + NSError *_Nullable error) { + callbackCount++; + receivedError = error; + }]; + XCTAssertNotNil(session); + XCTAssertEqual(callbackCount, 0u); + + NSError *first = [NSError errorWithDomain:OIDGeneralErrorDomain + code:OIDErrorCodeSafariOpenError + userInfo:@{NSLocalizedDescriptionKey : @"first"}]; + NSError *second = [NSError errorWithDomain:OIDGeneralErrorDomain + code:OIDErrorCodeSafariOpenError + userInfo:@{NSLocalizedDescriptionKey : @"second"}]; + + [session failExternalUserAgentFlowWithError:first]; + [session failExternalUserAgentFlowWithError:second]; + + XCTAssertEqual(callbackCount, 1u); + XCTAssertEqualObjects(receivedError.localizedDescription, @"first"); +} + +@end + +NS_ASSUME_NONNULL_END + +#pragma GCC diagnostic pop + +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST && !SWIFT_PACKAGE