diff --git a/AppAuth.xcodeproj/project.pbxproj b/AppAuth.xcodeproj/project.pbxproj index 7cfd05bc5..64496e053 100644 --- a/AppAuth.xcodeproj/project.pbxproj +++ b/AppAuth.xcodeproj/project.pbxproj @@ -606,6 +606,8 @@ CF37C0701F1FC21A00662E41 /* OIDEndSessionRequest.m in Sources */ = {isa = PBXBuildFile; fileRef = CF37C06C1F1FC21A00662E41 /* OIDEndSessionRequest.m */; }; CF37C0711F1FC21A00662E41 /* OIDEndSessionRequest.m in Sources */ = {isa = PBXBuildFile; fileRef = CF37C06C1F1FC21A00662E41 /* OIDEndSessionRequest.m */; }; CF6431F41F228A980075B6B5 /* OIDEndSessionResponse.m in Sources */ = {isa = PBXBuildFile; fileRef = CF6431F31F228A980075B6B5 /* OIDEndSessionResponse.m */; }; + E56963F03064815800AC4DFE /* OIDExternalUserAgentIOSTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E56963EF3064815800AC4DFE /* OIDExternalUserAgentIOSTests.m */; }; + E56963F13064815800AC4DFE /* OIDExternalUserAgentIOSTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E56963EF3064815800AC4DFE /* OIDExternalUserAgentIOSTests.m */; }; F9A7082E2355ED74004B3E6D /* OIDExternalUserAgentCatalyst.h in Headers */ = {isa = PBXBuildFile; fileRef = F9A7082C2355ED74004B3E6D /* OIDExternalUserAgentCatalyst.h */; settings = {ATTRIBUTES = (Public, ); }; }; F9A7082F2355ED74004B3E6D /* OIDExternalUserAgentCatalyst.m in Sources */ = {isa = PBXBuildFile; fileRef = F9A7082D2355ED74004B3E6D /* OIDExternalUserAgentCatalyst.m */; }; /* End PBXBuildFile section */ @@ -848,6 +850,7 @@ CF37C06C1F1FC21A00662E41 /* OIDEndSessionRequest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OIDEndSessionRequest.m; sourceTree = ""; }; CF6431F21F228A980075B6B5 /* OIDEndSessionResponse.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OIDEndSessionResponse.h; sourceTree = ""; }; CF6431F31F228A980075B6B5 /* OIDEndSessionResponse.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OIDEndSessionResponse.m; sourceTree = ""; }; + E56963EF3064815800AC4DFE /* OIDExternalUserAgentIOSTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OIDExternalUserAgentIOSTests.m; sourceTree = ""; }; F6F60FB01D2BFEFE00325CB3 /* OIDAuthState+IOS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "OIDAuthState+IOS.m"; sourceTree = ""; }; F6F60FB11D2BFEFE00325CB3 /* OIDAuthorizationService+IOS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "OIDAuthorizationService+IOS.m"; sourceTree = ""; }; F6F60FB31D2BFEFE00325CB3 /* OIDAuthorizationService+IOS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "OIDAuthorizationService+IOS.h"; sourceTree = ""; }; @@ -1109,6 +1112,7 @@ 341741FB1C5D82D3000EF209 /* UnitTests */ = { isa = PBXGroup; children = ( + E56963EF3064815800AC4DFE /* OIDExternalUserAgentIOSTests.m */, 2D52A08D24C24C260022E402 /* AppAuthTV */, 341742231C5D8317000EF209 /* UnitTestsInfo.plist */, 341742001C5D82D3000EF209 /* OIDAuthorizationRequestTests.h */, @@ -2206,6 +2210,7 @@ 341742211C5D82D3000EF209 /* OIDURLQueryComponentTests.m in Sources */, 341742201C5D82D3000EF209 /* OIDTokenResponseTests.m in Sources */, 341742221C5D82D3000EF209 /* OIDURLQueryComponentTestsIOS7.m in Sources */, + E56963F03064815800AC4DFE /* OIDExternalUserAgentIOSTests.m in Sources */, 3417421E1C5D82D3000EF209 /* OIDServiceDiscoveryTests.m in Sources */, 3417421F1C5D82D3000EF209 /* OIDTokenRequestTests.m in Sources */, 341742181C5D82D3000EF209 /* OIDAuthorizationResponseTests.m in Sources */, @@ -2386,6 +2391,7 @@ 343AAA7C1E8346B400F9D36E /* OIDTokenResponseTests.m in Sources */, 343AAA7B1E8346B400F9D36E /* OIDTokenRequestTests.m in Sources */, 343AAA771E8346B400F9D36E /* OIDResponseTypesTests.m in Sources */, + E56963F13064815800AC4DFE /* OIDExternalUserAgentIOSTests.m in Sources */, 343AAA7F1E8346B400F9D36E /* OIDRegistrationRequestTests.m in Sources */, 343AAA731E8346B400F9D36E /* OIDAuthorizationRequestTests.m in Sources */, 343AAA761E8346B400F9D36E /* OIDGrantTypesTests.m in Sources */, diff --git a/README.md b/README.md index 6a30e3973..ddbc86521 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,14 @@ Authentication is performed using `ASWebAuthenticationSession`. #### Authorization Server Requirements -Both Custom URI Schemes and Universal Links can be used with the library. +Both custom URI scheme redirects and HTTPS redirects (RFC 8252 "claimed https" redirect URIs) can +be used with the library. HTTPS redirects require iOS 17.4+, an Associated Domains entitlement +for the redirect host using the `webcredentials` service, and an AASA file on that host listing +the app under `webcredentials`. An `applinks` (Universal Links) association alone is not enough. In general, AppAuth can work with any authorization server that supports native apps, as documented in [RFC 8252](https://tools.ietf.org/html/rfc8252), -either through custom URI scheme redirects, or universal links. +either through custom URI scheme redirects, or HTTPS redirects. Authorization servers that assume all clients are web-based, or require clients to maintain confidentiality of the client secrets may not work well. diff --git a/Sources/AppAuth/iOS/OIDExternalUserAgentIOS.m b/Sources/AppAuth/iOS/OIDExternalUserAgentIOS.m index eb8c3f08d..b6e29bd2a 100644 --- a/Sources/AppAuth/iOS/OIDExternalUserAgentIOS.m +++ b/Sources/AppAuth/iOS/OIDExternalUserAgentIOS.m @@ -25,6 +25,8 @@ #import #import +#import "OIDAuthorizationRequest.h" +#import "OIDEndSessionRequest.h" #import "OIDErrorUtilities.h" #import "OIDExternalUserAgentSession.h" #import "OIDExternalUserAgentRequest.h" @@ -85,6 +87,30 @@ - (nullable instancetype)initWithPresentingViewController: return self; } +/*! @brief Creates the callback for a request whose redirect URL is an HTTPS URL. + @return The callback, or nil if the request is of an unsupported type, or its redirect URL is + not an HTTPS URL with a host. + */ ++ (nullable ASWebAuthenticationSessionCallback *)HTTPSCallbackForRequest: + (id)request API_AVAILABLE(ios(17.4)) { + NSURL *redirectURL = nil; + // OIDExternalUserAgentRequest does not conform to NSObject, so message the request as id. + id requestObject = request; + if ([requestObject isKindOfClass:[OIDAuthorizationRequest class]]) { + redirectURL = ((OIDAuthorizationRequest *)requestObject).redirectURL; + } else if ([requestObject isKindOfClass:[OIDEndSessionRequest class]]) { + redirectURL = ((OIDEndSessionRequest *)requestObject).postLogoutRedirectURL; + } + if (![[redirectURL.scheme lowercaseString] isEqualToString:@"https"] || + redirectURL.host.length == 0) { + return nil; + } + // A redirect URL with no path is normalized to the root path, so that it matches the callback + // URL the authorization server redirects to. + NSString *path = redirectURL.path.length > 0 ? redirectURL.path : @"/"; + return [ASWebAuthenticationSessionCallback callbackWithHTTPSHost:redirectURL.host path:path]; +} + - (BOOL)presentExternalUserAgentRequest:(id)request session:(id)session { if (_externalUserAgentFlowInProgress) { @@ -97,41 +123,59 @@ - (BOOL)presentExternalUserAgentRequest:(id)request BOOL openedUserAgent = NO; NSURL *requestURL = [request externalUserAgentRequestURL]; - // iOS 12 and later, use ASWebAuthenticationSession - if (@available(iOS 12.0, *)) { - // ASWebAuthenticationSession doesn't work with guided access (rdar://40809553) - if (!UIAccessibilityIsGuidedAccessEnabled()) { - __weak OIDExternalUserAgentIOS *weakSelf = self; - NSString *redirectScheme = request.redirectScheme; - ASWebAuthenticationSession *authenticationVC = - [[ASWebAuthenticationSession alloc] initWithURL:requestURL - callbackURLScheme:redirectScheme - completionHandler:^(NSURL * _Nullable callbackURL, - NSError * _Nullable error) { - __strong OIDExternalUserAgentIOS *strongSelf = weakSelf; - if (!strongSelf) { - return; + // ASWebAuthenticationSession doesn't work with guided access (rdar://40809553) + if (!UIAccessibilityIsGuidedAccessEnabled()) { + __weak OIDExternalUserAgentIOS *weakSelf = self; + ASWebAuthenticationSessionCompletionHandler completionHandler = + ^(NSURL * _Nullable callbackURL, NSError * _Nullable error) { + __strong OIDExternalUserAgentIOS *strongSelf = weakSelf; + if (!strongSelf) { + return; + } + strongSelf->_webAuthenticationVC = nil; + if (callbackURL) { + // The session matches callback URLs more loosely than the flow checks the redirect URL (a + // custom scheme callback matches on the scheme alone, and an HTTPS callback ignores case + // and the port), so the flow can reject the URL. Fail the flow if it does, rather than + // leaving it with neither a response nor an error. + NSError *resumeError; + if (![strongSelf->_session resumeExternalUserAgentFlowWithURL:callbackURL + error:&resumeError]) { + [strongSelf->_session failExternalUserAgentFlowWithError:resumeError]; } - strongSelf->_webAuthenticationVC = nil; - if (callbackURL) { - [strongSelf->_session resumeExternalUserAgentFlowWithURL:callbackURL error:nil]; - } else { - NSError *safariError = - [OIDErrorUtilities errorWithCode:OIDErrorCodeUserCanceledAuthorizationFlow - underlyingError:error - description:nil]; - [strongSelf->_session failExternalUserAgentFlowWithError:safariError]; + } else { + NSError *safariError = + [OIDErrorUtilities errorWithCode:OIDErrorCodeUserCanceledAuthorizationFlow + underlyingError:error + description:nil]; + [strongSelf->_session failExternalUserAgentFlowWithError:safariError]; + } + }; + + ASWebAuthenticationSession *authenticationVC = nil; + NSString *redirectScheme = request.redirectScheme; + if ([[redirectScheme lowercaseString] isEqualToString:@"https"]) { + // An HTTPS redirect URL needs the HTTPS callback added in iOS 17.4. + // Without one no session is started, as https is not supported as a callbackURLScheme: the + // session's callback would never fire. + if (@available(iOS 17.4, *)) { + ASWebAuthenticationSessionCallback *callback = + [[self class] HTTPSCallbackForRequest:request]; + if (callback) { + authenticationVC = [[ASWebAuthenticationSession alloc] initWithURL:requestURL + callback:callback + completionHandler:completionHandler]; } - }]; -#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000 - if (@available(iOS 13.0, *)) { - authenticationVC.presentationContextProvider = self; - authenticationVC.prefersEphemeralWebBrowserSession = _prefersEphemeralSession; } -#endif - _webAuthenticationVC = authenticationVC; - openedUserAgent = [authenticationVC start]; + } else { + authenticationVC = [[ASWebAuthenticationSession alloc] initWithURL:requestURL + callbackURLScheme:redirectScheme + completionHandler:completionHandler]; } + authenticationVC.presentationContextProvider = self; + authenticationVC.prefersEphemeralWebBrowserSession = _prefersEphemeralSession; + _webAuthenticationVC = authenticationVC; + openedUserAgent = [authenticationVC start]; } if (!openedUserAgent) { [self cleanUp]; diff --git a/UnitTests/OIDExternalUserAgentIOSTests.m b/UnitTests/OIDExternalUserAgentIOSTests.m new file mode 100644 index 000000000..7cc7fd8c6 --- /dev/null +++ b/UnitTests/OIDExternalUserAgentIOSTests.m @@ -0,0 +1,196 @@ +/*! @file OIDExternalUserAgentIOSTests.m + @brief AppAuth iOS SDK + @copyright + Copyright 2026 Google Inc. All Rights Reserved. + @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 + +// These tests exercise iOS-only code. They are excluded from the Swift package's test targets, +// which only depend on AppAuthCore. +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST && !SWIFT_PACKAGE + +#import + +#import + +#import "Sources/AppAuth/iOS/OIDExternalUserAgentIOS.h" +#import "Sources/AppAuthCore/OIDAuthorizationRequest.h" +#import "Sources/AppAuthCore/OIDEndSessionRequest.h" +#import "Sources/AppAuthCore/OIDExternalUserAgentRequest.h" +#import "Sources/AppAuthCore/OIDResponseTypes.h" +#import "Sources/AppAuthCore/OIDScopes.h" +#import "Sources/AppAuthCore/OIDServiceConfiguration.h" + +@interface OIDExternalUserAgentIOS (Testing) + // expose private method for simple testing ++ (nullable ASWebAuthenticationSessionCallback *)HTTPSCallbackForRequest: + (nonnull id)request API_AVAILABLE(ios(17.4)); +@end + +// 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" + +/*! @brief Test value for the @c clientID property. + */ +static NSString *const kTestClientID = @"ClientID"; + +/*! @brief Test value for the @c authorizationEndpoint property. + */ +static NSString *const kTestAuthorizationEndpoint = @"https://accounts.example.com/authorize"; + +/*! @brief Test value for the @c tokenEndpoint property. + */ +static NSString *const kTestTokenEndpoint = @"https://accounts.example.com/token"; + +/*! @brief Test value for the @c idTokenHint parameter. + */ +static NSString *const kTestIDTokenHint = @"id-token-hint"; + +/*! @brief A request of a type unknown to @c OIDExternalUserAgentIOS.HTTPSCallbackForRequest:. + */ +@interface OIDUnsupportedExternalUserAgentRequest : NSObject +@end + +@implementation OIDUnsupportedExternalUserAgentRequest + +- (NSURL *)externalUserAgentRequestURL { + return [NSURL URLWithString:kTestAuthorizationEndpoint]; +} + +- (NSString *)redirectScheme { + return @"https"; +} + +@end + +/*! @brief Unit tests for the iOS external user agent's HTTPS redirect callback support. + */ +@interface OIDExternalUserAgentIOSTests : XCTestCase +@end + +@implementation OIDExternalUserAgentIOSTests + +- (OIDAuthorizationRequest *)authorizationRequestWithRedirectURL:(NSURL *)redirectURL { + OIDServiceConfiguration *configuration = [[OIDServiceConfiguration alloc] + initWithAuthorizationEndpoint:[NSURL URLWithString:kTestAuthorizationEndpoint] + tokenEndpoint:[NSURL URLWithString:kTestTokenEndpoint]]; + return [[OIDAuthorizationRequest alloc] initWithConfiguration:configuration + clientId:kTestClientID + scopes:@[ OIDScopeOpenID ] + redirectURL:redirectURL + responseType:OIDResponseTypeCode + additionalParameters:nil]; +} + +/*! @brief An authorization request with an HTTPS redirect gets a callback matching that redirect. + */ +- (void)testHTTPSCallbackForAuthorizationRequest { + if (@available(iOS 17.4, *)) { + OIDAuthorizationRequest *request = [self authorizationRequestWithRedirectURL: + [NSURL URLWithString:@"https://client.example.com/oauth2redirect"]]; + ASWebAuthenticationSessionCallback *callback = [OIDExternalUserAgentIOS HTTPSCallbackForRequest:request]; + XCTAssertNotNil(callback); + XCTAssertTrue([callback matchesURL: + [NSURL URLWithString:@"https://client.example.com/oauth2redirect?code=1234"]]); + XCTAssertFalse([callback matchesURL: + [NSURL URLWithString:@"https://other.example.com/oauth2redirect?code=1234"]]); + } else { + XCTSkip(@"ASWebAuthenticationSessionCallback requires iOS 17.4."); + } +} + +/*! @brief An end session request with an HTTPS post-logout redirect gets a callback rather than + crashing on an unchecked cast. + */ +- (void)testHTTPSCallbackForEndSessionRequest { + if (@available(iOS 17.4, *)) { + OIDServiceConfiguration *configuration = [[OIDServiceConfiguration alloc] + initWithAuthorizationEndpoint:[NSURL URLWithString:kTestAuthorizationEndpoint] + tokenEndpoint:[NSURL URLWithString:kTestTokenEndpoint]]; + OIDEndSessionRequest *request = [[OIDEndSessionRequest alloc] + initWithConfiguration:configuration + idTokenHint:kTestIDTokenHint + postLogoutRedirectURL:[NSURL URLWithString:@"https://client.example.com/signout"] + additionalParameters:nil]; + ASWebAuthenticationSessionCallback *callback = [OIDExternalUserAgentIOS HTTPSCallbackForRequest:request]; + XCTAssertNotNil(callback); + XCTAssertTrue([callback matchesURL: + [NSURL URLWithString:@"https://client.example.com/signout?state=1234"]]); + } else { + XCTSkip(@"ASWebAuthenticationSessionCallback requires iOS 17.4."); + } +} + +/*! @brief A custom scheme redirect is not an HTTPS redirect, so no callback is created. + */ +- (void)testNoHTTPSCallbackForCustomSchemeRedirect { + if (@available(iOS 17.4, *)) { + OIDAuthorizationRequest *request = [self authorizationRequestWithRedirectURL: + [NSURL URLWithString:@"com.example.app:/oauth2redirect"]]; + XCTAssertNil([OIDExternalUserAgentIOS HTTPSCallbackForRequest:request]); + } else { + XCTSkip(@"ASWebAuthenticationSessionCallback requires iOS 17.4."); + } +} + +/*! @brief An HTTPS redirect without a host can never be matched, so no callback is created. + */ +- (void)testNoHTTPSCallbackForRedirectWithoutHost { + if (@available(iOS 17.4, *)) { + OIDAuthorizationRequest *request = [self authorizationRequestWithRedirectURL: + [NSURL URLWithString:@"https:///oauth2redirect"]]; + XCTAssertNil([OIDExternalUserAgentIOS HTTPSCallbackForRequest:request]); + } else { + XCTSkip(@"ASWebAuthenticationSessionCallback requires iOS 17.4."); + } +} + +/*! @brief An HTTPS redirect without a path matches on the root path. + */ +- (void)testHTTPSCallbackForRedirectWithoutPath { + if (@available(iOS 17.4, *)) { + OIDAuthorizationRequest *request = [self authorizationRequestWithRedirectURL: + [NSURL URLWithString:@"https://client.example.com"]]; + ASWebAuthenticationSessionCallback *callback = [OIDExternalUserAgentIOS HTTPSCallbackForRequest:request]; + XCTAssertNotNil(callback); + XCTAssertTrue([callback matchesURL: + [NSURL URLWithString:@"https://client.example.com/?code=1234"]]); + XCTAssertFalse([callback matchesURL: + [NSURL URLWithString:@"https://client.example.com/oauth2redirect?code=1234"]]); + } else { + XCTSkip(@"ASWebAuthenticationSessionCallback requires iOS 17.4."); + } +} + +/*! @brief Request types without a known redirect URL get no callback rather than crashing. + */ +- (void)testNoHTTPSCallbackForUnsupportedRequestType { + if (@available(iOS 17.4, *)) { + id request = + [[OIDUnsupportedExternalUserAgentRequest alloc] init]; + XCTAssertNil([OIDExternalUserAgentIOS HTTPSCallbackForRequest:request]); + } else { + XCTSkip(@"ASWebAuthenticationSessionCallback requires iOS 17.4."); + } +} + +@end + +#pragma GCC diagnostic pop + +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST && !SWIFT_PACKAGE