From 9d54480a0907eee119e45566f46f7c41199ae77d Mon Sep 17 00:00:00 2001 From: Donald Roshi Date: Fri, 26 Sep 2025 12:23:29 +0200 Subject: [PATCH 1/2] support universal link as redirect url For iOS, when version 17.4+ is available, check whether the redirect url is a universal link. If it is, use the new init method with callback accepting the universal link. --- Sources/AppAuth/iOS/OIDExternalUserAgentIOS.m | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/Sources/AppAuth/iOS/OIDExternalUserAgentIOS.m b/Sources/AppAuth/iOS/OIDExternalUserAgentIOS.m index eb8c3f08d..328a02044 100644 --- a/Sources/AppAuth/iOS/OIDExternalUserAgentIOS.m +++ b/Sources/AppAuth/iOS/OIDExternalUserAgentIOS.m @@ -25,6 +25,7 @@ #import #import +#import #import "OIDErrorUtilities.h" #import "OIDExternalUserAgentSession.h" #import "OIDExternalUserAgentRequest.h" @@ -97,10 +98,42 @@ - (BOOL)presentExternalUserAgentRequest:(id)request BOOL openedUserAgent = NO; NSURL *requestURL = [request externalUserAgentRequestURL]; + // iOS 17.4 and later, use ASWebAuthenticationSession with universal link + if (@available(iOS 17.4, *) && [[request.redirectScheme lowercaseString] isEqualToString:@"https"]) { + // ASWebAuthenticationSession doesn't work with guided access (rdar://40809553) + if (!UIAccessibilityIsGuidedAccessEnabled()) { + __weak OIDExternalUserAgentIOS *weakSelf = self; + NSURL *redirectURL = ((OIDAuthorizationRequest *)request).redirectURL; + ASWebAuthenticationSessionCallback *callback = [ASWebAuthenticationSessionCallback callbackWithHTTPSHost:redirectURL.host path:redirectURL.path]; + ASWebAuthenticationSession *authenticationVC = + [[ASWebAuthenticationSession alloc] initWithURL:requestURL + callback:callback + completionHandler:^(NSURL * _Nullable callbackURL, + NSError * _Nullable error) { + __strong OIDExternalUserAgentIOS *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]; + } + }]; + authenticationVC.presentationContextProvider = self; + authenticationVC.prefersEphemeralWebBrowserSession = NO; + _webAuthenticationVC = authenticationVC; + openedUserAgent = [authenticationVC start]; + } + } + // iOS 12 and later, use ASWebAuthenticationSession if (@available(iOS 12.0, *)) { // ASWebAuthenticationSession doesn't work with guided access (rdar://40809553) - if (!UIAccessibilityIsGuidedAccessEnabled()) { + if (!openedUserAgent && !UIAccessibilityIsGuidedAccessEnabled()) { __weak OIDExternalUserAgentIOS *weakSelf = self; NSString *redirectScheme = request.redirectScheme; ASWebAuthenticationSession *authenticationVC = From 09cbc594f00cdd61547e904754416004069b403b Mon Sep 17 00:00:00 2001 From: Khaleel Shaheen Date: Tue, 15 Sep 2026 02:22:28 +0300 Subject: [PATCH 2/2] Address review feedback for universal link support --- AppAuth.xcodeproj/project.pbxproj | 6 + Sources/AppAuth/iOS/OIDExternalUserAgentIOS.m | 71 ++++++- UnitTests/OIDExternalUserAgentIOSTests.m | 199 ++++++++++++++++++ 3 files changed, 265 insertions(+), 11 deletions(-) create mode 100644 UnitTests/OIDExternalUserAgentIOSTests.m diff --git a/AppAuth.xcodeproj/project.pbxproj b/AppAuth.xcodeproj/project.pbxproj index 7cfd05bc5..7c5d042c6 100644 --- a/AppAuth.xcodeproj/project.pbxproj +++ b/AppAuth.xcodeproj/project.pbxproj @@ -558,6 +558,8 @@ A5EEF29A20D821960044F470 /* OIDTokenUtilitiesTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A5EEF1FD20CF07760044F470 /* OIDTokenUtilitiesTests.m */; }; A5EEF29B20D821970044F470 /* OIDTokenUtilitiesTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A5EEF1FD20CF07760044F470 /* OIDTokenUtilitiesTests.m */; }; A5EEF29C20D821970044F470 /* OIDTokenUtilitiesTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A5EEF1FD20CF07760044F470 /* OIDTokenUtilitiesTests.m */; }; + AA00AF0100000000000AF001 /* OIDExternalUserAgentIOSTests.m in Sources */ = {isa = PBXBuildFile; fileRef = AA00AF0000000000000AF000 /* OIDExternalUserAgentIOSTests.m */; }; + AA00AF0200000000000AF002 /* OIDExternalUserAgentIOSTests.m in Sources */ = {isa = PBXBuildFile; fileRef = AA00AF0000000000000AF000 /* OIDExternalUserAgentIOSTests.m */; }; A6CEB11A2007E49C009D492A /* OIDEndSessionRequestTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A6CEB1182007E384009D492A /* OIDEndSessionRequestTests.m */; }; A6CEB11B2007E49D009D492A /* OIDEndSessionRequestTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A6CEB1182007E384009D492A /* OIDEndSessionRequestTests.m */; }; A6CEB11C2007E49E009D492A /* OIDEndSessionRequestTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A6CEB1182007E384009D492A /* OIDEndSessionRequestTests.m */; }; @@ -836,6 +838,7 @@ 73F574332B7C42690023FFF0 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; A5EEF1FD20CF07760044F470 /* OIDTokenUtilitiesTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OIDTokenUtilitiesTests.m; sourceTree = ""; }; A6CEB1172007E384009D492A /* OIDEndSessionRequestTests.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OIDEndSessionRequestTests.h; sourceTree = ""; }; + AA00AF0000000000000AF000 /* OIDExternalUserAgentIOSTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OIDExternalUserAgentIOSTests.m; sourceTree = ""; }; A6CEB1182007E384009D492A /* OIDEndSessionRequestTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OIDEndSessionRequestTests.m; sourceTree = ""; }; A6DEAB982018E4A20022AC32 /* OIDExternalUserAgent.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OIDExternalUserAgent.h; sourceTree = ""; }; A6DEAB992018E4A20022AC32 /* OIDExternalUserAgentSession.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OIDExternalUserAgentSession.h; sourceTree = ""; }; @@ -1122,6 +1125,7 @@ 341742071C5D82D3000EF209 /* OIDResponseTypesTests.m */, A6CEB1172007E384009D492A /* OIDEndSessionRequestTests.h */, A6CEB1182007E384009D492A /* OIDEndSessionRequestTests.m */, + AA00AF0000000000000AF000 /* OIDExternalUserAgentIOSTests.m */, 341742081C5D82D3000EF209 /* OIDScopesTests.m */, 341742091C5D82D3000EF209 /* OIDServiceConfigurationTests.h */, 3417420A1C5D82D3000EF209 /* OIDServiceConfigurationTests.m */, @@ -2202,6 +2206,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AA00AF0100000000000AF001 /* OIDExternalUserAgentIOSTests.m in Sources */, 34D5EC451E6D1AD900814354 /* OIDSwiftTests.swift in Sources */, 341742211C5D82D3000EF209 /* OIDURLQueryComponentTests.m in Sources */, 341742201C5D82D3000EF209 /* OIDTokenResponseTests.m in Sources */, @@ -2376,6 +2381,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AA00AF0200000000000AF002 /* OIDExternalUserAgentIOSTests.m in Sources */, 343AAA781E8346B400F9D36E /* OIDScopesTests.m in Sources */, 343AAA7D1E8346B400F9D36E /* OIDURLQueryComponentTests.m in Sources */, 343AAA791E8346B400F9D36E /* OIDServiceConfigurationTests.m in Sources */, diff --git a/Sources/AppAuth/iOS/OIDExternalUserAgentIOS.m b/Sources/AppAuth/iOS/OIDExternalUserAgentIOS.m index 328a02044..aef03dc37 100644 --- a/Sources/AppAuth/iOS/OIDExternalUserAgentIOS.m +++ b/Sources/AppAuth/iOS/OIDExternalUserAgentIOS.m @@ -25,7 +25,8 @@ #import #import -#import +#import "OIDAuthorizationRequest.h" +#import "OIDEndSessionRequest.h" #import "OIDErrorUtilities.h" #import "OIDExternalUserAgentSession.h" #import "OIDExternalUserAgentRequest.h" @@ -42,6 +43,27 @@ @interface OIDExternalUserAgentIOS () @end #endif +API_AVAILABLE(ios(17.4)) +ASWebAuthenticationSessionCallback *_Nullable + OIDHTTPSCallbackForRequest(id request) { + 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]; +} + @implementation OIDExternalUserAgentIOS { UIViewController *_presentingViewController; BOOL _prefersEphemeralSession; @@ -97,24 +119,51 @@ - (BOOL)presentExternalUserAgentRequest:(id)request _session = session; BOOL openedUserAgent = NO; NSURL *requestURL = [request externalUserAgentRequestURL]; + // An HTTPS redirect is a universal link, which only the iOS 17.4 callback below can handle. + // ASWebAuthenticationSession does not support @c https as a callbackURLScheme, so such a request + // must never reach the scheme based session: it would open a browser whose callback never fires. + // Before iOS 17.4 such a request therefore fails to open, ending the flow with an error rather + // than leaving it hanging. + BOOL hasHTTPSRedirect = [[request.redirectScheme lowercaseString] isEqualToString:@"https"]; - // iOS 17.4 and later, use ASWebAuthenticationSession with universal link - if (@available(iOS 17.4, *) && [[request.redirectScheme lowercaseString] isEqualToString:@"https"]) { + // iOS 17.4 and later: if the redirect URL is an HTTPS universal link, use + // ASWebAuthenticationSession's HTTPS callback. + if (@available(iOS 17.4, *)) { // ASWebAuthenticationSession doesn't work with guided access (rdar://40809553) - if (!UIAccessibilityIsGuidedAccessEnabled()) { + if (hasHTTPSRedirect && !UIAccessibilityIsGuidedAccessEnabled()) { + ASWebAuthenticationSessionCallback *callback = OIDHTTPSCallbackForRequest(request); + if (!callback) { + // The redirect URL is HTTPS but a callback couldn't be created for it (e.g. it has no + // host). A session started with such a redirect could never complete, so fail instead. + [self cleanUp]; + NSError *error = + [OIDErrorUtilities errorWithCode:OIDErrorCodeSafariOpenError + underlyingError:nil + description:@"The request's HTTPS redirect URL is not a valid " + "universal link."]; + [session failExternalUserAgentFlowWithError:error]; + return NO; + } __weak OIDExternalUserAgentIOS *weakSelf = self; - NSURL *redirectURL = ((OIDAuthorizationRequest *)request).redirectURL; - ASWebAuthenticationSessionCallback *callback = [ASWebAuthenticationSessionCallback callbackWithHTTPSHost:redirectURL.host path:redirectURL.path]; ASWebAuthenticationSession *authenticationVC = [[ASWebAuthenticationSession alloc] initWithURL:requestURL - callback:callback + callback:callback completionHandler:^(NSURL * _Nullable callbackURL, NSError * _Nullable error) { __strong OIDExternalUserAgentIOS *strongSelf = weakSelf; - if (!strongSelf) { return; } + if (!strongSelf) { + return; + } strongSelf->_webAuthenticationVC = nil; if (callbackURL) { - [strongSelf->_session resumeExternalUserAgentFlowWithURL:callbackURL]; + // The callback matches the redirect URL case insensitively and ignores its port, so it + // can fire for a URL the session itself rejects. Report that instead of leaving the + // flow with neither a response nor an error. + NSError *resumeError; + if (![strongSelf->_session resumeExternalUserAgentFlowWithURL:callbackURL + error:&resumeError]) { + [strongSelf->_session failExternalUserAgentFlowWithError:resumeError]; + } } else { NSError *safariError = [OIDErrorUtilities errorWithCode:OIDErrorCodeUserCanceledAuthorizationFlow @@ -124,7 +173,7 @@ - (BOOL)presentExternalUserAgentRequest:(id)request } }]; authenticationVC.presentationContextProvider = self; - authenticationVC.prefersEphemeralWebBrowserSession = NO; + authenticationVC.prefersEphemeralWebBrowserSession = _prefersEphemeralSession; _webAuthenticationVC = authenticationVC; openedUserAgent = [authenticationVC start]; } @@ -133,7 +182,7 @@ - (BOOL)presentExternalUserAgentRequest:(id)request // iOS 12 and later, use ASWebAuthenticationSession if (@available(iOS 12.0, *)) { // ASWebAuthenticationSession doesn't work with guided access (rdar://40809553) - if (!openedUserAgent && !UIAccessibilityIsGuidedAccessEnabled()) { + if (!hasHTTPSRedirect && !UIAccessibilityIsGuidedAccessEnabled()) { __weak OIDExternalUserAgentIOS *weakSelf = self; NSString *redirectScheme = request.redirectScheme; ASWebAuthenticationSession *authenticationVC = diff --git a/UnitTests/OIDExternalUserAgentIOSTests.m b/UnitTests/OIDExternalUserAgentIOSTests.m new file mode 100644 index 000000000..05a255e1b --- /dev/null +++ b/UnitTests/OIDExternalUserAgentIOSTests.m @@ -0,0 +1,199 @@ +/*! @file OIDExternalUserAgentIOSTests.m + @brief AppAuth iOS SDK + @copyright + Copyright 2025 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/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" + +/*! @brief Creates the @c ASWebAuthenticationSessionCallback for a request whose redirect URL is an + HTTPS universal link, or nil if one couldn't be created; for example, the request is of an + unsupported type, or its redirect URL is not a valid HTTPS URL with a host. + @discussion Implemented in @c OIDExternalUserAgentIOS.m and declared here rather than in a + header, so that it is testable without becoming part of AppAuth's public API. + */ +extern ASWebAuthenticationSessionCallback *_Nullable + OIDHTTPSCallbackForRequest(id _Nonnull request) + API_AVAILABLE(ios(17.4)); + +// 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 OIDHTTPSCallbackForRequest. + */ +@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 (universal link) 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 = OIDHTTPSCallbackForRequest(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 = OIDHTTPSCallbackForRequest(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 a universal link, so no callback is created. + */ +- (void)testNoHTTPSCallbackForCustomSchemeRedirect { + if (@available(iOS 17.4, *)) { + OIDAuthorizationRequest *request = [self authorizationRequestWithRedirectURL: + [NSURL URLWithString:@"com.example.app:/oauth2redirect"]]; + XCTAssertNil(OIDHTTPSCallbackForRequest(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(OIDHTTPSCallbackForRequest(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 = OIDHTTPSCallbackForRequest(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(OIDHTTPSCallbackForRequest(request)); + } else { + XCTSkip(@"ASWebAuthenticationSessionCallback requires iOS 17.4."); + } +} + +@end + +#pragma GCC diagnostic pop + +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST && !SWIFT_PACKAGE