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
6 changes: 6 additions & 0 deletions AppAuth.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -836,6 +838,7 @@
73F574332B7C42690023FFF0 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
A5EEF1FD20CF07760044F470 /* OIDTokenUtilitiesTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OIDTokenUtilitiesTests.m; sourceTree = "<group>"; };
A6CEB1172007E384009D492A /* OIDEndSessionRequestTests.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OIDEndSessionRequestTests.h; sourceTree = "<group>"; };
AA00AF0000000000000AF000 /* OIDExternalUserAgentIOSTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OIDExternalUserAgentIOSTests.m; sourceTree = "<group>"; };
A6CEB1182007E384009D492A /* OIDEndSessionRequestTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OIDEndSessionRequestTests.m; sourceTree = "<group>"; };
A6DEAB982018E4A20022AC32 /* OIDExternalUserAgent.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OIDExternalUserAgent.h; sourceTree = "<group>"; };
A6DEAB992018E4A20022AC32 /* OIDExternalUserAgentSession.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OIDExternalUserAgentSession.h; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
84 changes: 83 additions & 1 deletion Sources/AppAuth/iOS/OIDExternalUserAgentIOS.m
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
#import <SafariServices/SafariServices.h>
#import <AuthenticationServices/AuthenticationServices.h>

#import "OIDAuthorizationRequest.h"
#import "OIDEndSessionRequest.h"
#import "OIDErrorUtilities.h"
#import "OIDExternalUserAgentSession.h"
#import "OIDExternalUserAgentRequest.h"
Expand All @@ -41,6 +43,27 @@ @interface OIDExternalUserAgentIOS ()<SFSafariViewControllerDelegate>
@end
#endif

API_AVAILABLE(ios(17.4))
ASWebAuthenticationSessionCallback *_Nullable
OIDHTTPSCallbackForRequest(id<OIDExternalUserAgentRequest> 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;
Expand Down Expand Up @@ -96,11 +119,70 @@ - (BOOL)presentExternalUserAgentRequest:(id<OIDExternalUserAgentRequest>)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: 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 (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;
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) {
// 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
underlyingError:error
description:nil];
[strongSelf->_session failExternalUserAgentFlowWithError:safariError];
}
}];
authenticationVC.presentationContextProvider = self;
authenticationVC.prefersEphemeralWebBrowserSession = _prefersEphemeralSession;
_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 (!hasHTTPSRedirect && !UIAccessibilityIsGuidedAccessEnabled()) {
__weak OIDExternalUserAgentIOS *weakSelf = self;
NSString *redirectScheme = request.redirectScheme;
ASWebAuthenticationSession *authenticationVC =
Expand Down
199 changes: 199 additions & 0 deletions UnitTests/OIDExternalUserAgentIOSTests.m
Original file line number Diff line number Diff line change
@@ -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 <TargetConditionals.h>

// 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 <XCTest/XCTest.h>

#import <AuthenticationServices/AuthenticationServices.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"

/*! @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<OIDExternalUserAgentRequest> _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 <OIDExternalUserAgentRequest>
@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<OIDExternalUserAgentRequest> 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