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
5 changes: 5 additions & 0 deletions .changeset/scene-aware-ios-presentation.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 11 additions & 4 deletions packages/react-native-app-auth/ios/RNAppAuth.m
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#import <React/RCTLog.h>
#import <React/RCTConvert.h>
#import "RNAppAuthAuthorizationFlowManager.h"
#import "RNAppAuthPresentation.h"

@interface RNAppAuth()<RNAppAuthAuthorizationFlowManagerDelegate> {
id<OIDExternalUserAgentSession> _currentSession;
Expand Down Expand Up @@ -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;

Expand All @@ -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<OIDExternalUserAgent> externalUserAgent = nil;
#elif TARGET_OS_IOS
Expand Down Expand Up @@ -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;

Expand All @@ -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<OIDExternalUserAgent> externalUserAgent = nil;
#elif TARGET_OS_IOS
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand All @@ -23,6 +24,8 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
AA1148000000000000000002 /* RNAppAuthPresentation.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNAppAuthPresentation.m; sourceTree = "<group>"; };
AA1148000000000000000003 /* RNAppAuthPresentation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNAppAuthPresentation.h; sourceTree = "<group>"; };
02A9A6ED212C1BC200FAAB86 /* RNAppAuthAuthorizationFlowManagerDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNAppAuthAuthorizationFlowManagerDelegate.h; sourceTree = "<group>"; };
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 = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -117,6 +122,7 @@
buildActionMask = 2147483647;
files = (
B3E7B58A1CC2AC0600A0062D /* RNAppAuth.m in Sources */,
AA1148000000000000000001 /* RNAppAuthPresentation.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
3 changes: 3 additions & 0 deletions packages/react-native-app-auth/ios/RNAppAuthPresentation.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#import <UIKit/UIKit.h>

UIViewController *RNAppAuthPresentingViewController(UIApplication *application);
73 changes: 73 additions & 0 deletions packages/react-native-app-auth/ios/RNAppAuthPresentation.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#import "RNAppAuthPresentation.h"

static UIWindow *RNAppAuthWindowWithRootViewController(NSArray<UIWindow *> *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<UIApplicationDelegate> 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;
}
164 changes: 164 additions & 0 deletions packages/react-native-app-auth/native-tests/ios-presentation.m
Original file line number Diff line number Diff line change
@@ -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 <UIWindowSceneDelegate>
@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 <UIApplicationDelegate>
@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));
}
}
49 changes: 49 additions & 0 deletions packages/react-native-app-auth/native-tests/ios-presentation.sh
Original file line number Diff line number Diff line change
@@ -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'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleIdentifier</key><string>com.rnappauth.presenter-tests</string>
<key>CFBundleExecutable</key><string>PresenterTests</string>
<key>CFBundleName</key><string>PresenterTests</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleVersion</key><string>1</string>
<key>CFBundleShortVersionString</key><string>1.0</string>
<key>MinimumOSVersion</key><string>13.0</string>
<key>UIApplicationSceneManifest</key><dict>
<key>UIApplicationSupportsMultipleScenes</key><false/>
<key>UISceneConfigurations</key><dict>
<key>UIWindowSceneSessionRoleApplication</key><array><dict>
<key>UISceneConfigurationName</key><string>Default</string>
<key>UISceneDelegateClassName</key><string>RunnerSceneDelegate</string>
</dict></array></dict></dict>
</dict></plist>
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"
1 change: 1 addition & 0 deletions packages/react-native-app-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading