From 6410316d8476b75ee62bd1223e62975f176926f7 Mon Sep 17 00:00:00 2001 From: Cuc Dan Mihai Date: Fri, 5 Jun 2026 11:22:25 +0300 Subject: [PATCH 1/6] feat(ios): intercept https redirect URIs natively via ASWebAuthenticationSession callback (iOS 17.4+) With an https (universal link) redirect URI, the session is created with callbackURLScheme:@"https", which ASWebAuthenticationSession does not support, so it never intercepts the redirect. Completion then depends on the universal link opening the app from inside the auth session - which is not triggered by server redirects or JS navigation and sporadically never happens, leaving authorize() pending forever with the user stuck on a disabled login UI (#987, #932; see also openid/AppAuth-iOS#367). On iOS 17.4+ Apple provides ASWebAuthenticationSessionCallback callbackWithHTTPSHost:path:, letting the session intercept the https redirect natively - no app-site-association dependency, no trampoline pages, no custom scheme. This adds an external user agent built on that API and uses it automatically when no iosCustomBrowser is requested and the redirect URI scheme is https. Behavior on iOS < 17.4 is unchanged. Co-Authored-By: Claude --- .changeset/https-callback-ios.md | 5 + .../react-native-app-auth/ios/RNAppAuth.m | 125 ++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 .changeset/https-callback-ios.md diff --git a/.changeset/https-callback-ios.md b/.changeset/https-callback-ios.md new file mode 100644 index 000000000..8ef614f55 --- /dev/null +++ b/.changeset/https-callback-ios.md @@ -0,0 +1,5 @@ +--- +'react-native-app-auth': minor +--- + +iOS: natively intercept https (universal link) redirect URIs on iOS 17.4+ using the ASWebAuthenticationSession https callback, so the authorization flow no longer depends on universal-link activation from inside the auth session (which is not triggered by server redirects and sporadically leaves authorize() pending forever). diff --git a/packages/react-native-app-auth/ios/RNAppAuth.m b/packages/react-native-app-auth/ios/RNAppAuth.m index c48163427..985e0d75b 100644 --- a/packages/react-native-app-auth/ios/RNAppAuth.m +++ b/packages/react-native-app-auth/ios/RNAppAuth.m @@ -7,6 +7,27 @@ #import #import #import "RNAppAuthAuthorizationFlowManager.h" +#import + +/** + * External user agent that uses the iOS 17.4+ ASWebAuthenticationSession https callback + * (ASWebAuthenticationSessionCallback callbackWithHTTPSHost:path:). With an https + * (universal link) redirect URI, AppAuth-iOS passes "https" as the callbackURLScheme — + * which ASWebAuthenticationSession does not support — so the session never intercepts + * the redirect and the flow has to rely on the universal link opening the app, which is + * not triggered by server redirects/JS navigation inside the session and is sporadically + * dropped, leaving authorize() pending forever (#987, #932; openid/AppAuth-iOS#367). + * This agent lets the session intercept the https redirect natively. + */ +API_AVAILABLE(ios(17.4)) +@interface RNAppAuthHTTPSExternalUserAgent : NSObject + +- (nonnull instancetype)initWithPresentingViewController:(nonnull UIViewController *)presentingViewController + prefersEphemeralSession:(BOOL)prefersEphemeralSession + host:(nonnull NSString *)host + path:(nonnull NSString *)path; + +@end @interface RNAppAuth() { id _currentSession; @@ -380,6 +401,20 @@ - (void)authorizeWithConfiguration: (OIDServiceConfiguration *) configuration id externalUserAgent = nil; #elif TARGET_OS_IOS id externalUserAgent = iosCustomBrowser != nil ? [self getCustomBrowser: iosCustomBrowser] : nil; + // Prefer the native https-callback session for https (universal link) redirect URIs + // (see RNAppAuthHTTPSExternalUserAgent above). Falls back to default behavior pre-17.4. + if (externalUserAgent == nil) { + if (@available(iOS 17.4, *)) { + NSURL *httpsRedirectURL = [NSURL URLWithString:redirectUrl]; + if ([httpsRedirectURL.scheme isEqualToString:@"https"] && httpsRedirectURL.host != nil) { + externalUserAgent = [[RNAppAuthHTTPSExternalUserAgent alloc] + initWithPresentingViewController:presentingViewController + prefersEphemeralSession:prefersEphemeralSession + host:httpsRedirectURL.host + path:httpsRedirectURL.path.length > 0 ? httpsRedirectURL.path : @"/"]; + } + } + } #endif OIDAuthorizationCallback callback = ^(OIDAuthorizationResponse *_Nullable authorizationResponse, NSError *_Nullable error) { @@ -803,3 +838,93 @@ - (void)rejectPromise:(RCTPromiseRejectBlock)reject } @end + +/** + * Implementation modeled on AppAuth's OIDExternalUserAgentIOS, but built with + * [ASWebAuthenticationSessionCallback callbackWithHTTPSHost:path:] so the session + * intercepts the https redirect itself (iOS 17.4+). + */ +@implementation RNAppAuthHTTPSExternalUserAgent { + UIViewController *_presentingViewController; + BOOL _prefersEphemeralSession; + NSString *_host; + NSString *_path; + BOOL _externalUserAgentFlowInProgress; + __weak id _session; + ASWebAuthenticationSession *_webAuthenticationSession; +} + +- (instancetype)initWithPresentingViewController:(UIViewController *)presentingViewController + prefersEphemeralSession:(BOOL)prefersEphemeralSession + host:(NSString *)host + path:(NSString *)path { + self = [super init]; + if (self) { + _presentingViewController = presentingViewController; + _prefersEphemeralSession = prefersEphemeralSession; + _host = [host copy]; + _path = [path copy]; + } + return self; +} + +- (BOOL)presentExternalUserAgentRequest:(id)request + session:(id)session { + if (_externalUserAgentFlowInProgress) { + return NO; + } + _externalUserAgentFlowInProgress = YES; + _session = session; + + NSURL *requestURL = [request externalUserAgentRequestURL]; + __weak typeof(self) weakSelf = self; + + ASWebAuthenticationSessionCallback *callback = + [ASWebAuthenticationSessionCallback callbackWithHTTPSHost:_host path:_path]; + ASWebAuthenticationSession *webAuthenticationSession = + [[ASWebAuthenticationSession alloc] initWithURL:requestURL + callback:callback + completionHandler:^(NSURL *_Nullable callbackURL, NSError *_Nullable error) { + typeof(self) strongSelf = weakSelf; + if (!strongSelf) { + return; + } + strongSelf->_webAuthenticationSession = nil; + if (callbackURL) { + [strongSelf->_session resumeExternalUserAgentFlowWithURL:callbackURL]; + } else { + NSError *safariError = + [OIDErrorUtilities errorWithCode:OIDErrorCodeUserCanceledAuthorizationFlow + underlyingError:error + description:nil]; + [strongSelf->_session failExternalUserAgentFlowWithError:safariError]; + } + }]; + + webAuthenticationSession.presentationContextProvider = self; + webAuthenticationSession.prefersEphemeralWebBrowserSession = _prefersEphemeralSession; + _webAuthenticationSession = webAuthenticationSession; + return [webAuthenticationSession start]; +} + +- (void)dismissExternalUserAgentAnimated:(BOOL)animated completion:(nonnull void (^)(void))completion { + if (!_externalUserAgentFlowInProgress) { + if (completion) { + completion(); + } + return; + } + _externalUserAgentFlowInProgress = NO; + [_webAuthenticationSession cancel]; + _webAuthenticationSession = nil; + _session = nil; + if (completion) { + completion(); + } +} + +- (ASPresentationAnchor)presentationAnchorForWebAuthenticationSession:(ASWebAuthenticationSession *)session { + return _presentingViewController.view.window; +} + +@end From 669e1d6c7c922c54b6be0fbf50c0026316922744 Mon Sep 17 00:00:00 2001 From: Cuc Dan Mihai Date: Fri, 5 Jun 2026 11:23:38 +0300 Subject: [PATCH 2/6] chore: add issue refs to changeset Co-Authored-By: Claude --- .changeset/https-callback-ios.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/https-callback-ios.md b/.changeset/https-callback-ios.md index 8ef614f55..d2c65deaa 100644 --- a/.changeset/https-callback-ios.md +++ b/.changeset/https-callback-ios.md @@ -2,4 +2,4 @@ 'react-native-app-auth': minor --- -iOS: natively intercept https (universal link) redirect URIs on iOS 17.4+ using the ASWebAuthenticationSession https callback, so the authorization flow no longer depends on universal-link activation from inside the auth session (which is not triggered by server redirects and sporadically leaves authorize() pending forever). +iOS: natively intercept https (universal link) redirect URIs on iOS 17.4+ using the ASWebAuthenticationSession https callback, so the authorization flow no longer depends on universal-link activation from inside the auth session — which is not triggered by server redirects and sporadically leaves authorize() pending forever (#987, #932). From 0d8b281e3431311f582c9d6b4ab4029ea44134b6 Mon Sep 17 00:00:00 2001 From: Cuc Dan Mihai Date: Fri, 5 Jun 2026 11:27:43 +0300 Subject: [PATCH 3/6] chore: drop changeset Co-Authored-By: Claude --- .changeset/https-callback-ios.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/https-callback-ios.md diff --git a/.changeset/https-callback-ios.md b/.changeset/https-callback-ios.md deleted file mode 100644 index d2c65deaa..000000000 --- a/.changeset/https-callback-ios.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'react-native-app-auth': minor ---- - -iOS: natively intercept https (universal link) redirect URIs on iOS 17.4+ using the ASWebAuthenticationSession https callback, so the authorization flow no longer depends on universal-link activation from inside the auth session — which is not triggered by server redirects and sporadically leaves authorize() pending forever (#987, #932). From d7e51af83c6daafb6e51a730358c4ceb1fc1dbec Mon Sep 17 00:00:00 2001 From: Cuc Dan Mihai Date: Fri, 5 Jun 2026 11:38:12 +0300 Subject: [PATCH 4/6] chore: add changeset (minor) Co-Authored-By: Claude --- .changeset/https-callback-ios.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/https-callback-ios.md diff --git a/.changeset/https-callback-ios.md b/.changeset/https-callback-ios.md new file mode 100644 index 000000000..d2c65deaa --- /dev/null +++ b/.changeset/https-callback-ios.md @@ -0,0 +1,5 @@ +--- +'react-native-app-auth': minor +--- + +iOS: natively intercept https (universal link) redirect URIs on iOS 17.4+ using the ASWebAuthenticationSession https callback, so the authorization flow no longer depends on universal-link activation from inside the auth session — which is not triggered by server redirects and sporadically leaves authorize() pending forever (#987, #932). From 1d997c52a9d313f770cfdeed6f87261380ee1e9d Mon Sep 17 00:00:00 2001 From: Cuc Dan Mihai Date: Fri, 5 Jun 2026 13:55:50 +0300 Subject: [PATCH 5/6] fix(ios): fall back to legacy session when webcredentials association is missing The https callback requires the callback host to be an associated domain with the webcredentials service type. Without it the session refuses to start (start returns NO, or the completion handler fires immediately with a non-cancel error), which would hard-fail every sign-in. Detect both cases and transparently fall back to the legacy callbackURLScheme session, preserving AppAuth's default behavior for apps without the association. Co-Authored-By: Claude --- .../react-native-app-auth/ios/RNAppAuth.m | 85 ++++++++++++++----- 1 file changed, 66 insertions(+), 19 deletions(-) diff --git a/packages/react-native-app-auth/ios/RNAppAuth.m b/packages/react-native-app-auth/ios/RNAppAuth.m index 985e0d75b..b13133826 100644 --- a/packages/react-native-app-auth/ios/RNAppAuth.m +++ b/packages/react-native-app-auth/ios/RNAppAuth.m @@ -18,6 +18,11 @@ * not triggered by server redirects/JS navigation inside the session and is sporadically * dropped, leaving authorize() pending forever (#987, #932; openid/AppAuth-iOS#367). * This agent lets the session intercept the https redirect natively. + * + * Requires the callback host to be an associated domain with the webcredentials service + * type (entitlement + apple-app-site-association). When the association is missing the + * agent transparently falls back to the legacy callbackURLScheme session, preserving + * AppAuth's default behavior. */ API_AVAILABLE(ios(17.4)) @interface RNAppAuthHTTPSExternalUserAgent : NSObject @@ -850,8 +855,10 @@ @implementation RNAppAuthHTTPSExternalUserAgent { NSString *_host; NSString *_path; BOOL _externalUserAgentFlowInProgress; + BOOL _didFallBackToLegacySession; __weak id _session; ASWebAuthenticationSession *_webAuthenticationSession; + NSURL *_requestURL; } - (instancetype)initWithPresentingViewController:(UIViewController *)presentingViewController @@ -875,16 +882,38 @@ - (BOOL)presentExternalUserAgentRequest:(id)request } _externalUserAgentFlowInProgress = YES; _session = session; + _requestURL = [request externalUserAgentRequestURL]; - NSURL *requestURL = [request externalUserAgentRequestURL]; - __weak typeof(self) weakSelf = self; + ASWebAuthenticationSession *webAuthenticationSession = [self authenticationSessionWithHTTPSCallback:YES]; + _webAuthenticationSession = webAuthenticationSession; + if ([webAuthenticationSession start]) { + return YES; + } + return [self startLegacyFallbackSession]; +} + +/** + * The https callback requires the callback host to be an associated domain with the + * webcredentials service type (entitlement + apple-app-site-association entry). When the + * association is missing or not yet validated, the session refuses to start — either + * start returns NO or the completion handler fires immediately with a non-cancel error. + * In both cases fall back to the legacy callbackURLScheme session, which matches + * AppAuth's default behavior, so sign-in keeps working instead of hard-failing. + */ +- (BOOL)startLegacyFallbackSession { + if (_didFallBackToLegacySession) { + return NO; + } + _didFallBackToLegacySession = YES; + ASWebAuthenticationSession *fallbackSession = [self authenticationSessionWithHTTPSCallback:NO]; + _webAuthenticationSession = fallbackSession; + return [fallbackSession start]; +} - ASWebAuthenticationSessionCallback *callback = - [ASWebAuthenticationSessionCallback callbackWithHTTPSHost:_host path:_path]; - ASWebAuthenticationSession *webAuthenticationSession = - [[ASWebAuthenticationSession alloc] initWithURL:requestURL - callback:callback - completionHandler:^(NSURL *_Nullable callbackURL, NSError *_Nullable error) { +- (ASWebAuthenticationSession *)authenticationSessionWithHTTPSCallback:(BOOL)useHTTPSCallback { + __weak typeof(self) weakSelf = self; + void (^completionHandler)(NSURL *_Nullable, NSError *_Nullable) = + ^(NSURL *_Nullable callbackURL, NSError *_Nullable error) { typeof(self) strongSelf = weakSelf; if (!strongSelf) { return; @@ -892,19 +921,37 @@ - (BOOL)presentExternalUserAgentRequest:(id)request strongSelf->_webAuthenticationSession = nil; if (callbackURL) { [strongSelf->_session resumeExternalUserAgentFlowWithURL:callbackURL]; - } else { - NSError *safariError = - [OIDErrorUtilities errorWithCode:OIDErrorCodeUserCanceledAuthorizationFlow - underlyingError:error - description:nil]; - [strongSelf->_session failExternalUserAgentFlowWithError:safariError]; + return; } - }]; + BOOL isUserCancel = [error.domain isEqualToString:ASWebAuthenticationSessionErrorDomain] && + error.code == ASWebAuthenticationSessionErrorCodeCanceledLogin; + if (useHTTPSCallback && !isUserCancel && [strongSelf startLegacyFallbackSession]) { + // Missing/unvalidated webcredentials association — legacy session took over + return; + } + NSError *safariError = + [OIDErrorUtilities errorWithCode:OIDErrorCodeUserCanceledAuthorizationFlow + underlyingError:error + description:nil]; + [strongSelf->_session failExternalUserAgentFlowWithError:safariError]; + }; - webAuthenticationSession.presentationContextProvider = self; - webAuthenticationSession.prefersEphemeralWebBrowserSession = _prefersEphemeralSession; - _webAuthenticationSession = webAuthenticationSession; - return [webAuthenticationSession start]; + ASWebAuthenticationSession *session; + if (useHTTPSCallback) { + ASWebAuthenticationSessionCallback *callback = + [ASWebAuthenticationSessionCallback callbackWithHTTPSHost:_host path:_path]; + session = [[ASWebAuthenticationSession alloc] initWithURL:_requestURL + callback:callback + completionHandler:completionHandler]; + } else { + // Matches AppAuth's OIDExternalUserAgentIOS default for https redirect URIs + session = [[ASWebAuthenticationSession alloc] initWithURL:_requestURL + callbackURLScheme:@"https" + completionHandler:completionHandler]; + } + session.presentationContextProvider = self; + session.prefersEphemeralWebBrowserSession = _prefersEphemeralSession; + return session; } - (void)dismissExternalUserAgentAnimated:(BOOL)animated completion:(nonnull void (^)(void))completion { From 44201c0e96800d2cba71c02049516b0df670a709 Mon Sep 17 00:00:00 2001 From: Cuc Dan Mihai Date: Fri, 5 Jun 2026 14:14:18 +0300 Subject: [PATCH 6/6] fix(ios): detect webcredentials-association failure despite cancel error code The missing-association failure is reported with the SAME error code as a user cancellation (ASWebAuthenticationSessionErrorCodeCanceledLogin), so the previous fallback check never engaged and every sign-in hard-failed ('Application ... is not associated with domain ... Using HTTPS callbacks requires Associated Domains using the webcredentials service type'). The association failure carries an NSLocalizedFailureReason while genuine user cancellations do not - use that to trigger the legacy-session fallback. Verified on an iOS 26 simulator: with the association missing, the fallback engages and sign-in completes; with a newer login attempt started, stale resolutions are still discarded. Co-Authored-By: Claude --- .../react-native-app-auth/ios/RNAppAuth.m | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/react-native-app-auth/ios/RNAppAuth.m b/packages/react-native-app-auth/ios/RNAppAuth.m index b13133826..3be93d9b7 100644 --- a/packages/react-native-app-auth/ios/RNAppAuth.m +++ b/packages/react-native-app-auth/ios/RNAppAuth.m @@ -12,8 +12,8 @@ /** * External user agent that uses the iOS 17.4+ ASWebAuthenticationSession https callback * (ASWebAuthenticationSessionCallback callbackWithHTTPSHost:path:). With an https - * (universal link) redirect URI, AppAuth-iOS passes "https" as the callbackURLScheme — - * which ASWebAuthenticationSession does not support — so the session never intercepts + * (universal link) redirect URI, AppAuth-iOS passes "https" as the callbackURLScheme - + * which ASWebAuthenticationSession does not support - so the session never intercepts * the redirect and the flow has to rely on the universal link opening the app, which is * not triggered by server redirects/JS navigation inside the session and is sporadically * dropped, leaving authorize() pending forever (#987, #932; openid/AppAuth-iOS#367). @@ -22,7 +22,7 @@ * Requires the callback host to be an associated domain with the webcredentials service * type (entitlement + apple-app-site-association). When the association is missing the * agent transparently falls back to the legacy callbackURLScheme session, preserving - * AppAuth's default behavior. + * AppAuth default behavior. */ API_AVAILABLE(ios(17.4)) @interface RNAppAuthHTTPSExternalUserAgent : NSObject @@ -845,8 +845,8 @@ - (void)rejectPromise:(RCTPromiseRejectBlock)reject @end /** - * Implementation modeled on AppAuth's OIDExternalUserAgentIOS, but built with - * [ASWebAuthenticationSessionCallback callbackWithHTTPSHost:path:] so the session + * Implementation modeled on AppAuth's OIDExternalUserAgentIOS, but built + * with [ASWebAuthenticationSessionCallback callbackWithHTTPSHost:path:] so the session * intercepts the https redirect itself (iOS 17.4+). */ @implementation RNAppAuthHTTPSExternalUserAgent { @@ -923,10 +923,13 @@ - (ASWebAuthenticationSession *)authenticationSessionWithHTTPSCallback:(BOOL)use [strongSelf->_session resumeExternalUserAgentFlowWithURL:callbackURL]; return; } - BOOL isUserCancel = [error.domain isEqualToString:ASWebAuthenticationSessionErrorDomain] && - error.code == ASWebAuthenticationSessionErrorCodeCanceledLogin; - if (useHTTPSCallback && !isUserCancel && [strongSelf startLegacyFallbackSession]) { - // Missing/unvalidated webcredentials association — legacy session took over + // A missing webcredentials association is reported with the SAME error code as a + // user cancellation (ASWebAuthenticationSessionErrorCodeCanceledLogin) — but it + // carries an NSLocalizedFailureReason ("...requires Associated Domains using the + // `webcredentials` service type..."), which genuine user cancellations do not. + NSString *failureReason = error.userInfo[NSLocalizedFailureReasonErrorKey]; + if (useHTTPSCallback && failureReason.length > 0 && [strongSelf startLegacyFallbackSession]) { + // Association missing/unvalidated — legacy session took over return; } NSError *safariError =