Skip to content
Merged
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 @@ -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 */
Expand Down Expand Up @@ -848,6 +850,7 @@
CF37C06C1F1FC21A00662E41 /* OIDEndSessionRequest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OIDEndSessionRequest.m; sourceTree = "<group>"; };
CF6431F21F228A980075B6B5 /* OIDEndSessionResponse.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OIDEndSessionResponse.h; sourceTree = "<group>"; };
CF6431F31F228A980075B6B5 /* OIDEndSessionResponse.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OIDEndSessionResponse.m; sourceTree = "<group>"; };
E56963EF3064815800AC4DFE /* OIDExternalUserAgentIOSTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OIDExternalUserAgentIOSTests.m; sourceTree = "<group>"; };
F6F60FB01D2BFEFE00325CB3 /* OIDAuthState+IOS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "OIDAuthState+IOS.m"; sourceTree = "<group>"; };
F6F60FB11D2BFEFE00325CB3 /* OIDAuthorizationService+IOS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "OIDAuthorizationService+IOS.m"; sourceTree = "<group>"; };
F6F60FB31D2BFEFE00325CB3 /* OIDAuthorizationService+IOS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "OIDAuthorizationService+IOS.h"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1109,6 +1112,7 @@
341741FB1C5D82D3000EF209 /* UnitTests */ = {
isa = PBXGroup;
children = (
E56963EF3064815800AC4DFE /* OIDExternalUserAgentIOSTests.m */,
2D52A08D24C24C260022E402 /* AppAuthTV */,
341742231C5D8317000EF209 /* UnitTestsInfo.plist */,
341742001C5D82D3000EF209 /* OIDAuthorizationRequestTests.h */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
106 changes: 75 additions & 31 deletions 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 Down Expand Up @@ -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<OIDExternalUserAgentRequest>)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<OIDExternalUserAgentRequest>)request
session:(id<OIDExternalUserAgentSession>)session {
if (_externalUserAgentFlowInProgress) {
Expand All @@ -97,41 +123,59 @@ - (BOOL)presentExternalUserAgentRequest:(id<OIDExternalUserAgentRequest>)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];
Expand Down
196 changes: 196 additions & 0 deletions UnitTests/OIDExternalUserAgentIOSTests.m
Original file line number Diff line number Diff line change
@@ -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 <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/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<OIDExternalUserAgentRequest>)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 <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 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<OIDExternalUserAgentRequest> 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