diff --git a/.changeset/scene-aware-ios-presentation.md b/.changeset/scene-aware-ios-presentation.md new file mode 100644 index 000000000..212389557 --- /dev/null +++ b/.changeset/scene-aware-ios-presentation.md @@ -0,0 +1,5 @@ +--- +"react-native-app-auth": patch +--- + +Support scene-based iOS authorization and logout presentation while preserving legacy app windows and older iOS versions. diff --git a/packages/react-native-app-auth/ios/RNAppAuth.m b/packages/react-native-app-auth/ios/RNAppAuth.m index c48163427..c1181d6d5 100644 --- a/packages/react-native-app-auth/ios/RNAppAuth.m +++ b/packages/react-native-app-auth/ios/RNAppAuth.m @@ -7,6 +7,7 @@ #import #import #import "RNAppAuthAuthorizationFlowManager.h" +#import "RNAppAuthPresentation.h" @interface RNAppAuth() { id _currentSession; @@ -366,6 +367,11 @@ - (void)authorizeWithConfiguration: (OIDServiceConfiguration *) configuration [NSException raise:@"RNAppAuth Missing protocol conformance" format:@"%@ does not conform to RNAppAuthAuthorizationFlowManager", appDelegate]; } + UIViewController *presentingViewController = RNAppAuthPresentingViewController(UIApplication.sharedApplication); + if (presentingViewController == nil) { + reject(@"authentication_failed", @"No foreground window is available to present authorization.", nil); + return; + } appDelegate.authorizationFlowManagerDelegate = self; __weak typeof(self) weakSelf = self; @@ -374,8 +380,6 @@ - (void)authorizeWithConfiguration: (OIDServiceConfiguration *) configuration rnAppAuthTaskId = UIBackgroundTaskInvalid; }]; - UIViewController *presentingViewController = appDelegate.window.rootViewController.view.window ? appDelegate.window.rootViewController : appDelegate.window.rootViewController.presentedViewController; - #if TARGET_OS_MACCATALYST id externalUserAgent = nil; #elif TARGET_OS_IOS @@ -512,6 +516,11 @@ - (void)endSessionWithConfiguration: (OIDServiceConfiguration *) configuration [NSException raise:@"RNAppAuth Missing protocol conformance" format:@"%@ does not conform to RNAppAuthAuthorizationFlowManager", appDelegate]; } + UIViewController *presentingViewController = RNAppAuthPresentingViewController(UIApplication.sharedApplication); + if (presentingViewController == nil) { + reject(@"end_session_failed", @"No foreground window is available to present logout.", nil); + return; + } appDelegate.authorizationFlowManagerDelegate = self; __weak typeof(self) weakSelf = self; @@ -520,8 +529,6 @@ - (void)endSessionWithConfiguration: (OIDServiceConfiguration *) configuration rnAppAuthTaskId = UIBackgroundTaskInvalid; }]; - UIViewController *presentingViewController = appDelegate.window.rootViewController.view.window ? appDelegate.window.rootViewController : appDelegate.window.rootViewController.presentedViewController; - #if TARGET_OS_MACCATALYST id externalUserAgent = nil; #elif TARGET_OS_IOS diff --git a/packages/react-native-app-auth/ios/RNAppAuth.xcodeproj/project.pbxproj b/packages/react-native-app-auth/ios/RNAppAuth.xcodeproj/project.pbxproj index 9b692589a..fdb28441f 100644 --- a/packages/react-native-app-auth/ios/RNAppAuth.xcodeproj/project.pbxproj +++ b/packages/react-native-app-auth/ios/RNAppAuth.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + AA1148000000000000000001 /* RNAppAuthPresentation.m in Sources */ = {isa = PBXBuildFile; fileRef = AA1148000000000000000002 /* RNAppAuthPresentation.m */; }; B3E7B58A1CC2AC0600A0062D /* RNAppAuth.m in Sources */ = {isa = PBXBuildFile; fileRef = B3E7B5891CC2AC0600A0062D /* RNAppAuth.m */; }; /* End PBXBuildFile section */ @@ -23,6 +24,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + AA1148000000000000000002 /* RNAppAuthPresentation.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNAppAuthPresentation.m; sourceTree = ""; }; + AA1148000000000000000003 /* RNAppAuthPresentation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNAppAuthPresentation.h; sourceTree = ""; }; 02A9A6ED212C1BC200FAAB86 /* RNAppAuthAuthorizationFlowManagerDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNAppAuthAuthorizationFlowManagerDelegate.h; sourceTree = ""; }; 134814201AA4EA6300B7C361 /* libRNAppAuth.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libRNAppAuth.a; sourceTree = BUILT_PRODUCTS_DIR; }; 19C8EE5020E3796B00C4D522 /* RNAppAuthAuthorizationFlowManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNAppAuthAuthorizationFlowManager.h; sourceTree = ""; }; @@ -54,6 +57,8 @@ children = ( B3E7B5881CC2AC0600A0062D /* RNAppAuth.h */, B3E7B5891CC2AC0600A0062D /* RNAppAuth.m */, + AA1148000000000000000002 /* RNAppAuthPresentation.m */, + AA1148000000000000000003 /* RNAppAuthPresentation.h */, 19C8EE5020E3796B00C4D522 /* RNAppAuthAuthorizationFlowManager.h */, 02A9A6ED212C1BC200FAAB86 /* RNAppAuthAuthorizationFlowManagerDelegate.h */, 134814211AA4EA7D00B7C361 /* Products */, @@ -117,6 +122,7 @@ buildActionMask = 2147483647; files = ( B3E7B58A1CC2AC0600A0062D /* RNAppAuth.m in Sources */, + AA1148000000000000000001 /* RNAppAuthPresentation.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/packages/react-native-app-auth/ios/RNAppAuthPresentation.h b/packages/react-native-app-auth/ios/RNAppAuthPresentation.h new file mode 100644 index 000000000..4a208331f --- /dev/null +++ b/packages/react-native-app-auth/ios/RNAppAuthPresentation.h @@ -0,0 +1,3 @@ +#import + +UIViewController *RNAppAuthPresentingViewController(UIApplication *application); diff --git a/packages/react-native-app-auth/ios/RNAppAuthPresentation.m b/packages/react-native-app-auth/ios/RNAppAuthPresentation.m new file mode 100644 index 000000000..ba844c4bb --- /dev/null +++ b/packages/react-native-app-auth/ios/RNAppAuthPresentation.m @@ -0,0 +1,73 @@ +#import "RNAppAuthPresentation.h" + +static UIWindow *RNAppAuthWindowWithRootViewController(NSArray *windows) { + UIWindow *fallback = nil; + for (UIWindow *window in windows) { + if (window.hidden || window.rootViewController == nil) { + continue; + } + if (window.isKeyWindow) { + return window; + } + if (window.windowLevel == UIWindowLevelNormal) { + fallback = window; + } + } + return fallback; +} + +static UIWindow *RNAppAuthPresentationWindow(UIApplication *application) { + id appDelegate = application.delegate; + UIWindow *delegateWindow = [appDelegate respondsToSelector:@selector(window)] ? appDelegate.window : nil; + + if (@available(iOS 13.0, *)) { + // Prefer the window owned by the host app, when it belongs to a foreground scene. + if (delegateWindow.rootViewController != nil && !delegateWindow.hidden && + (delegateWindow.windowScene == nil || + delegateWindow.windowScene.activationState == UISceneActivationStateForegroundActive || + delegateWindow.windowScene.activationState == UISceneActivationStateForegroundInactive)) { + return delegateWindow; + } + + for (NSInteger state = UISceneActivationStateForegroundActive; + state <= UISceneActivationStateForegroundInactive; state++) { + UIWindow *candidate = nil; + for (UIScene *scene in application.connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]] || scene.activationState != state) { + continue; + } + UIWindow *window = RNAppAuthWindowWithRootViewController(((UIWindowScene *)scene).windows); + if (window == nil) { + continue; + } + // The bridge has no initiating scene identifier. Do not pick an arbitrary scene. + if (candidate != nil) { + return nil; + } + candidate = window; + } + if (candidate != nil) { + return candidate; + } + } + + if (application.connectedScenes.count != 0) { + return nil; + } + } else if (delegateWindow.rootViewController != nil && !delegateWindow.hidden) { + return delegateWindow; + } + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + return RNAppAuthWindowWithRootViewController(application.windows); +#pragma clang diagnostic pop +} + +UIViewController *RNAppAuthPresentingViewController(UIApplication *application) { + UIViewController *viewController = RNAppAuthPresentationWindow(application).rootViewController; + while (viewController.presentedViewController != nil) { + viewController = viewController.presentedViewController; + } + return viewController; +} diff --git a/packages/react-native-app-auth/native-tests/ios-presentation.m b/packages/react-native-app-auth/native-tests/ios-presentation.m new file mode 100644 index 000000000..dd99cd9d9 --- /dev/null +++ b/packages/react-native-app-auth/native-tests/ios-presentation.m @@ -0,0 +1,164 @@ +#import "RNAppAuthPresentation.h" + +// Lightweight UIKit fixtures exercise the production resolver without React Native or an IdP. +@interface TestApplication : NSObject +@property(nonatomic, strong) id delegate; +@property(nonatomic, strong) NSSet *connectedScenes; +@property(nonatomic, strong) NSArray *windows; +@end +@implementation TestApplication +@end + +@interface TestScene : NSObject +@property(nonatomic) UISceneActivationState activationState; +@property(nonatomic, strong) NSArray *windows; +@end +@implementation TestScene +- (BOOL)isKindOfClass:(Class)class { + return class == UIWindowScene.class || [super isKindOfClass:class]; +} +@end + +@interface TestWindow : NSObject +@property(nonatomic) BOOL testKeyWindow; +@property(nonatomic, strong) TestScene *testScene; +@property(nonatomic, strong) UIViewController *rootViewController; +@property(nonatomic, getter=isHidden) BOOL hidden; +@property(nonatomic) UIWindowLevel windowLevel; +@end +@implementation TestWindow +- (BOOL)isKeyWindow { return self.testKeyWindow; } +- (UIWindowScene *)windowScene { return (UIWindowScene *)self.testScene; } +@end + +@interface TestViewController : UIViewController +@property(nonatomic, strong) UIViewController *testPresentedViewController; +@end +@implementation TestViewController +- (UIViewController *)presentedViewController { return self.testPresentedViewController; } +@end + +@interface TestDelegate : NSObject +@property(nonatomic, strong) UIWindow *window; +@end +@implementation TestDelegate +@end + +static TestWindow *Window(BOOL key) { + TestWindow *window = [TestWindow new]; + window.rootViewController = [TestViewController new]; + window.hidden = NO; + window.testKeyWindow = key; + return window; +} + +static TestScene *Scene(UISceneActivationState state, NSArray *windows) { + TestScene *scene = [TestScene new]; + scene.activationState = state; + scene.windows = windows; + for (TestWindow *window in windows) { window.testScene = scene; } + return scene; +} + +static void Check(TestApplication *application, UIViewController *expected, NSString *name) { + UIViewController *actual = RNAppAuthPresentingViewController((UIApplication *)application); + if (actual != expected) { + fprintf(stderr, "FAIL: %s\n", name.UTF8String); + exit(1); + } + fprintf(stderr, "PASS: %s\n", name.UTF8String); +} + +static void RunTests(void) { + TestApplication *application = [TestApplication new]; + TestDelegate *delegate = [TestDelegate new]; + application.delegate = delegate; + application.connectedScenes = [NSSet set]; + application.windows = @[]; + + TestWindow *legacy = Window(YES); + delegate.window = (UIWindow *)legacy; + Check(application, legacy.rootViewController, @"legacy app-delegate window"); + + UIViewController *modal = [UIViewController new]; + ((TestViewController *)legacy.rootViewController).testPresentedViewController = modal; + Check(application, modal, @"topmost presented modal"); + ((TestViewController *)legacy.rootViewController).testPresentedViewController = nil; + + delegate.window = nil; + application.windows = @[legacy]; + Check(application, legacy.rootViewController, @"legacy application-window fallback"); + application.windows = @[]; + + TestWindow *content = Window(NO); + TestWindow *key = Window(YES); + TestWindow *background = Window(YES); + TestScene *active = Scene(UISceneActivationStateForegroundActive, @[content, key]); + TestScene *backgroundScene = Scene(UISceneActivationStateBackground, @[background]); + application.connectedScenes = [NSSet setWithArray:@[backgroundScene, active]]; + Check(application, key.rootViewController, @"foreground key window wins over background"); + + key.rootViewController = nil; + Check(application, content.rootViewController, @"rootless key window does not hide content window"); + key.rootViewController = [TestViewController new]; + key.hidden = YES; + Check(application, content.rootViewController, @"hidden key window is ignored"); + key.hidden = NO; + + TestWindow *inactive = Window(YES); + TestScene *inactiveScene = Scene(UISceneActivationStateForegroundInactive, @[inactive]); + application.connectedScenes = [NSSet setWithArray:@[inactiveScene, active]]; + Check(application, key.rootViewController, @"active scene wins over inactive scene"); + application.connectedScenes = [NSSet setWithObject:inactiveScene]; + Check(application, inactive.rootViewController, @"temporarily inactive foreground scene"); + + TestWindow *other = Window(YES); + TestScene *otherScene = Scene(UISceneActivationStateForegroundActive, @[other]); + application.connectedScenes = [NSSet setWithArray:@[active, otherScene]]; + Check(application, nil, @"ambiguous foreground scenes are rejected"); + delegate.window = (UIWindow *)key; + Check(application, key.rootViewController, @"host-owned window disambiguates scenes"); + delegate.window = (UIWindow *)background; + application.connectedScenes = [NSSet setWithArray:@[active, backgroundScene]]; + Check(application, key.rootViewController, @"background delegate window is ignored"); + + application.connectedScenes = [NSSet setWithObject:backgroundScene]; + application.windows = @[background]; + Check(application, nil, @"background-only app cannot present"); + application.connectedScenes = [NSSet set]; + application.windows = @[]; + delegate.window = nil; + Check(application, nil, @"missing window is rejected"); + fprintf(stderr, "All 13 iOS presenter cases passed.\n"); + NSString *resultPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject + stringByAppendingPathComponent:@"presenter-results.txt"]; + [@"All 13 iOS presenter cases passed.\n" writeToFile:resultPath atomically:YES encoding:NSUTF8StringEncoding error:nil]; +} + +@interface RunnerSceneDelegate : UIResponder +@property(nonatomic, strong) UIWindow *window; +@end +@implementation RunnerSceneDelegate +- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)options { + self.window = [[UIWindow alloc] initWithWindowScene:(UIWindowScene *)scene]; + self.window.rootViewController = [UIViewController new]; + [self.window makeKeyAndVisible]; + RunTests(); + exit(0); +} +@end + +@interface RunnerDelegate : UIResponder +@property(nonatomic, strong) UIWindow *window; +@end +@implementation RunnerDelegate +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)options { + return YES; +} +@end + +int main(int argc, char *argv[]) { + @autoreleasepool { + return UIApplicationMain(argc, argv, nil, NSStringFromClass(RunnerDelegate.class)); + } +} diff --git a/packages/react-native-app-auth/native-tests/ios-presentation.sh b/packages/react-native-app-auth/native-tests/ios-presentation.sh new file mode 100644 index 000000000..964c4c987 --- /dev/null +++ b/packages/react-native-app-auth/native-tests/ios-presentation.sh @@ -0,0 +1,49 @@ +#!/bin/bash +set -euo pipefail + +simulator_id="${1:?Pass the UDID of a booted iOS simulator}" +package_dir="$(cd "$(dirname "$0")/.." && pwd)" +test_dir="$(mktemp -d "${TMPDIR:-/tmp}/rnaa-ios-presentation.XXXXXX")" +sdk_path="$(xcrun --sdk iphonesimulator --show-sdk-path)" +architecture="$(uname -m)" +trap 'rm -rf "$test_dir"' EXIT + +# Check availability independently of the runtime simulator's OS version. +for minimum_ios in 10.0 12.0 14.0 15.0; do + xcrun clang -fsyntax-only -fobjc-arc -Werror=unguarded-availability-new \ + -target "$architecture-apple-ios$minimum_ios-simulator" -isysroot "$sdk_path" \ + "$package_dir/ios/RNAppAuthPresentation.m" +done + +mkdir "$test_dir/PresenterTests.app" +cat > "$test_dir/PresenterTests.app/Info.plist" <<'PLIST' + + + +CFBundleIdentifiercom.rnappauth.presenter-tests +CFBundleExecutablePresenterTests +CFBundleNamePresenterTests +CFBundlePackageTypeAPPL +CFBundleVersion1 +CFBundleShortVersionString1.0 +MinimumOSVersion13.0 +UIApplicationSceneManifest +UIApplicationSupportsMultipleScenes +UISceneConfigurations +UIWindowSceneSessionRoleApplication +UISceneConfigurationNameDefault +UISceneDelegateClassNameRunnerSceneDelegate + + +PLIST + +xcrun clang -fobjc-arc -target "$architecture-apple-ios13.0-simulator" \ + -isysroot "$sdk_path" -framework UIKit -framework Foundation \ + -I "$package_dir/ios" "$package_dir/ios/RNAppAuthPresentation.m" \ + "$package_dir/native-tests/ios-presentation.m" \ + -o "$test_dir/PresenterTests.app/PresenterTests" +xcrun simctl uninstall "$simulator_id" com.rnappauth.presenter-tests || true +xcrun simctl install "$simulator_id" "$test_dir/PresenterTests.app" +xcrun simctl launch --console "$simulator_id" com.rnappauth.presenter-tests +data_dir="$(xcrun simctl get_app_container "$simulator_id" com.rnappauth.presenter-tests data)" +cat "$data_dir/Documents/presenter-results.txt" diff --git a/packages/react-native-app-auth/package.json b/packages/react-native-app-auth/package.json index 3f05ecb01..97310e280 100644 --- a/packages/react-native-app-auth/package.json +++ b/packages/react-native-app-auth/package.json @@ -6,6 +6,7 @@ "types": "index.d.ts", "scripts": { "test": "jest", + "test:ios-presenter": "bash native-tests/ios-presentation.sh", "lint": "eslint .", "build": "expo-module build plugin", "clean": "expo-module clean plugin",