Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support SFSafariViewController #800

Merged
merged 31 commits into from
Nov 28, 2023
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f5d51ba
Support SFSafariViewController
poovamraj Nov 10, 2023
fd55191
Move try block below
poovamraj Nov 10, 2023
20b1d25
Merge branch 'master' into support-sfsafariviewcontroller
poovamraj Nov 10, 2023
83fda69
Merge branch 'master' into support-sfsafariviewcontroller
poovamraj Nov 13, 2023
73fa655
Fixed failing tests
poovamraj Nov 14, 2023
bed6bda
Update src/types.ts
poovamraj Nov 15, 2023
abe4ec4
Add equal parameters in Android
poovamraj Nov 19, 2023
13d0b02
Call remove only if `linkSubscription` exists
poovamraj Nov 19, 2023
ef66887
Add tests to check when SFSafariViewController is enabled
poovamraj Nov 20, 2023
24f2ebe
Update FAQ.md
poovamraj Nov 20, 2023
cccd117
Simplify resumeWebAuth native code
poovamraj Nov 20, 2023
5dc4f87
Update packageManager version
poovamraj Nov 20, 2023
91ed971
Remove `useSFSafariViewController` for logout
poovamraj Nov 20, 2023
e188c5c
Merge branch 'master' into support-sfsafariviewcontroller
poovamraj Nov 20, 2023
65dfe3a
Run tests with corepack enabled
poovamraj Nov 20, 2023
888b3df
Remove package manager tag from package.json
poovamraj Nov 21, 2023
f587412
Remove link subscription from logout
poovamraj Nov 21, 2023
ce73ef0
Add more unit tests
poovamraj Nov 21, 2023
24b56de
Merge branch 'master' into support-sfsafariviewcontroller
Widcket Nov 21, 2023
217d4be
Update FAQ.md
poovamraj Nov 21, 2023
be61ae3
Merge branch 'master' into support-sfsafariviewcontroller
poovamraj Nov 27, 2023
0c793f6
Implement SafariViewControllerPresentationStyle
poovamraj Nov 27, 2023
9c49f8a
Add tests to implement safariviewcontroller presenter style
poovamraj Nov 27, 2023
6f5e73c
Update documentation
poovamraj Nov 27, 2023
4f6a8e1
Implement review feedback
poovamraj Nov 28, 2023
511dc7c
Fix unit test case
poovamraj Nov 28, 2023
4da4f57
Add another unit test to test success case
poovamraj Nov 28, 2023
5170b3e
coalesce promises together
poovamraj Nov 28, 2023
2a85c9f
Support boolean values for useSFSafariViewController
poovamraj Nov 28, 2023
5c373f0
Update documentation
poovamraj Nov 28, 2023
07d45eb
Update documentation
poovamraj Nov 28, 2023
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
29 changes: 24 additions & 5 deletions FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,14 @@ Alternatively, you can re-declare the `RedirectActivity` in the `AndroidManifest

![ios-sso-alert](assets/ios-sso-alert.png)

Under the hood, react-native-auth0 uses `ASWebAuthenticationSession` to perform web-based authentication on iOS 12+, which is the [API provided by Apple](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession) for such purpose.
Under the hood, react-native-auth0 uses `ASWebAuthenticationSession` by default to perform web-based authentication, which is the [API provided by Apple](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession) for such purpose.

That alert box is displayed and managed by `ASWebAuthenticationSession`, not by react-native-auth0, because by default this API will store the session cookie in the shared Safari cookie jar. This makes Single Sign-On (SSO) possible. According to Apple, that requires user consent.
That alert box is displayed and managed by `ASWebAuthenticationSession`, not by react-native-auth0, because by default this API will store the session cookie in the shared Safari cookie jar. This makes single sign-on (SSO) possible. According to Apple, that requires user consent.

> :bulb: See [this blog post](https://developer.okta.com/blog/2022/01/13/mobile-sso) for a detailed overview of SSO on iOS.
> **Note**
> See [this blog post](https://developer.okta.com/blog/2022/01/13/mobile-sso) for a detailed overview of SSO on iOS.

### Use ephemeral sessions

If you don't need SSO, you can disable this behavior by adding `ephemeralSession: true` to the login call. This will configure `ASWebAuthenticationSession` to not store the session cookie in the shared cookie jar, as if using an incognito browser window. With no shared cookie, `ASWebAuthenticationSession` will not prompt the user for consent.

Expand All @@ -77,7 +80,22 @@ Note that with `ephemeralSession: true` you don't need to call `clearSession` at

You still need to call `clearSession` on Android, though, as `ephemeralSession` is iOS-only.

> :bulb: `ephemeralSession` relies on the `prefersEphemeralWebBrowserSession` configuration option of `ASWebAuthenticationSession`. This option is only available on [iOS 13+](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession/3237231-prefersephemeralwebbrowsersessio), so `ephemeralSession` will have no effect on older iOS versions. To improve the experience for users on older iOS versions, see the approach described below.
### Use `SFSafariViewController`

An alternative is to use `SFSafariViewController` instead of `ASWebAuthenticationSession`. You can do so with the built-in `SFSafariViewController` Web Auth provider:

```js
auth0.webAuth
.authorize(
{ scope: 'openid profile email' },
{ useSFSafariViewController: {} } // Use SFSafariViewController
)
.then((credentials) => console.log(credentials))
.catch((error) => console.log(error));
```

> **Note**
> Since `SFSafariViewController` does not share cookies with the Safari app, SSO will not work either. But it will keep its own cookies, so you can use it to perform SSO between your app and your website as long as you open it inside your app using `SFSafariViewController`. This also means that any feature that relies on the persistence of cookies will work as expected.

## 3. How can I disable the iOS _logout_ alert box?

Expand All @@ -99,7 +117,8 @@ auth0.webAuth

Otherwise, the browser modal will close right away and the user will be automatically logged in again, as the cookie will still be there.

> :warning: Keeping the shared session cookie may not be an option if you have strong privacy and/or security requirements, for example in the case of a banking app.
> **Warning**
> Keeping the shared session cookie may not be an option if you have strong privacy and/or security requirements, for example in the case of a banking app.

## 4. Is there a way to disable the iOS _login_ alert box without `ephemeralSession`?

Expand Down
2 changes: 1 addition & 1 deletion android/src/main/java/com/auth0/react/A0Auth0Module.java
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ public String getName() {
}

@ReactMethod
public void webAuth(String scheme, String redirectUri, String state, String nonce, String audience, String scope, String connection, int maxAge, String organization, String invitationUrl, int leeway, boolean ephemeralSession, ReadableMap additionalParameters, Promise promise) {
public void webAuth(String scheme, String redirectUri, String state, String nonce, String audience, String scope, String connection, int maxAge, String organization, String invitationUrl, int leeway, boolean ephemeralSession, int safariViewControllerPresentationStyle, ReadableMap additionalParameters, Promise promise) {
this.webAuthPromise = promise;
Map<String,String> cleanedParameters = new HashMap<>();
for (Map.Entry<String, Object> entry : additionalParameters.toHashMap().entrySet()) {
Expand Down
7 changes: 7 additions & 0 deletions example/ios/Auth0Example/AppDelegate.mm
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#import "AppDelegate.h"

#import <React/RCTBundleURLProvider.h>
#import <React/RCTLinkingManager.h>

@implementation AppDelegate

Expand All @@ -14,6 +15,12 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(
return [super application:application didFinishLaunchingWithOptions:launchOptions];
}

- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url
options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options
{
return [RCTLinkingManager application:app openURL:url options:options];
}

- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
#if DEBUG
Expand Down
8 changes: 6 additions & 2 deletions ios/A0Auth0.m
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,18 @@ - (dispatch_queue_t)methodQueue
[self.nativeBridge enableLocalAuthenticationWithTitle:title cancelTitle:cancelTitle fallbackTitle:fallbackTitle evaluationPolicy: evaluationPolicy];
}

RCT_EXPORT_METHOD(webAuth:(NSString *)scheme redirectUri:(NSString *)redirectUri state:(NSString *)state nonce:(NSString *)nonce audience:(NSString *)audience scope:(NSString *)scope connection:(NSString *)connection maxAge:(NSInteger)maxAge organization:(NSString *)organization invitationUrl:(NSString *)invitationUrl leeway:(NSInteger)leeway ephemeralSession:(BOOL)ephemeralSession additionalParameters:(NSDictionary *)additionalParameters resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) {
[self.nativeBridge webAuthWithState:state redirectUri:redirectUri nonce:nonce audience:audience scope:scope connection:connection maxAge:maxAge organization:organization invitationUrl:invitationUrl leeway:leeway ephemeralSession:ephemeralSession additionalParameters:additionalParameters resolve:resolve reject:reject];
RCT_EXPORT_METHOD(webAuth:(NSString *)scheme redirectUri:(NSString *)redirectUri state:(NSString *)state nonce:(NSString *)nonce audience:(NSString *)audience scope:(NSString *)scope connection:(NSString *)connection maxAge:(NSInteger)maxAge organization:(NSString *)organization invitationUrl:(NSString *)invitationUrl leeway:(NSInteger)leeway ephemeralSession:(BOOL)ephemeralSession safariViewControllerPresentationStyle:(NSInteger)safariViewControllerPresentationStyle additionalParameters:(NSDictionary *)additionalParameters resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) {
[self.nativeBridge webAuthWithState:state redirectUri:redirectUri nonce:nonce audience:audience scope:scope connection:connection maxAge:maxAge organization:organization invitationUrl:invitationUrl leeway:leeway ephemeralSession:ephemeralSession safariViewControllerPresentationStyle:safariViewControllerPresentationStyle additionalParameters:additionalParameters resolve:resolve reject:reject];
}

RCT_EXPORT_METHOD(webAuthLogout:(NSString *)scheme federated:(BOOL)federated redirectUri:(NSString *)redirectUri resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) {
[self.nativeBridge webAuthLogoutWithFederated:federated redirectUri:redirectUri resolve:resolve reject:reject];
}

RCT_EXPORT_METHOD(resumeWebAuth:(NSString *)url resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) {
[self.nativeBridge resumeWebAuthWithUrl:url resolve:resolve reject:reject];
}

- (NSDictionary *)constantsToExport {
return @{ @"bundleIdentifier": [[NSBundle mainBundle] bundleIdentifier] };
}
Expand Down
18 changes: 15 additions & 3 deletions ios/NativeBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public class NativeBridge: NSObject {
super.init()
}

@objc public func webAuth(state: String?, redirectUri: String, nonce: String?, audience: String?, scope: String?, connection: String?, maxAge: Int, organization: String?, invitationUrl: String?, leeway: Int, ephemeralSession: Bool, additionalParameters: [String: String], resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
@objc public func webAuth(state: String?, redirectUri: String, nonce: String?, audience: String?, scope: String?, connection: String?, maxAge: Int, organization: String?, invitationUrl: String?, leeway: Int, ephemeralSession: Bool, safariViewControllerPresentationStyle: Int, additionalParameters: [String: String], resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
let builder = Auth0.webAuth(clientId: self.clientId, domain: self.domain)
if let value = URL(string: redirectUri) {
let _ = builder.redirectURL(value)
Expand Down Expand Up @@ -73,7 +73,11 @@ public class NativeBridge: NSObject {
if(ephemeralSession) {
let _ = builder.useEphemeralSession()
}
let _ = builder.parameters(additionalParameters)
if let presentationStyle = UIModalPresentationStyle(rawValue: safariViewControllerPresentationStyle), safariViewControllerPresentationStyle != 99 {
let _ = builder.provider(WebAuthentication.safariProvider(style: presentationStyle))
}
let _ = builder
.parameters(additionalParameters)
builder.start { result in
switch result {
case .success(let credentials):
Expand All @@ -84,7 +88,7 @@ public class NativeBridge: NSObject {
}

}

poovamraj marked this conversation as resolved.
Show resolved Hide resolved
@objc public func webAuthLogout(federated: Bool, redirectUri: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
let builder = Auth0.webAuth(clientId: self.clientId, domain: self.domain)
if let value = URL(string: redirectUri) {
Expand All @@ -99,6 +103,14 @@ public class NativeBridge: NSObject {
}
}
}

@objc public func resumeWebAuth(url: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
if let value = URL(string: url), WebAuthentication.resume(with: value) {
resolve(true)
} else {
reject("ERROR_PARSING_URL", "The callback url \(url) is invalid", nil)
}
}

@objc public func saveCredentials(credentialsDict: [String: Any], resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {

Expand Down
3 changes: 3 additions & 0 deletions src/internal-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,15 @@ export type Auth0Module = {
invitationUrl?: string,
leeway?: number,
ephemeralSession?: boolean,
safariViewControllerPresentationStyle?: number,
additionalParameters?: { [key: string]: string }
) => Promise<Credentials>;
webAuthLogout: (
scheme: string,
federated: boolean,
redirectUri: string
) => Promise<void>;
resumeWebAuth: (url: string) => Promise<void>;
saveCredentials: (credentials: Credentials) => Promise<void>;
getCredentials: (
scope?: string,
Expand Down Expand Up @@ -133,6 +135,7 @@ export interface AgentLoginOptions {
customScheme?: string;
leeway?: number;
ephemeralSession?: boolean;
safariViewControllerPresentationStyle?: number;
additionalParameters?: { [key: string]: string };
useLegacyCallbackUrl?: boolean;
}
29 changes: 29 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,16 @@ export interface WebAuthorizeOptions {
* **Android only:** Custom scheme to build the callback URL with.
*/
customScheme?: string;
/**
* This will use older callback URL. See {@link https://github.com/auth0/react-native-auth0/blob/master/MIGRATION_GUIDE.md#callback-url-migration} for more details.
*/
useLegacyCallbackUrl?: boolean;
/**
* **iOS only:** Uses `SFSafariViewController` instead of `ASWebAuthenticationSession`. If empty object is set, the presentationStyle defaults to {@link SafariViewControllerPresentationStyle.fullScreen}
*/
useSFSafariViewController?: {
presentationStyle?: SafariViewControllerPresentationStyle;
};
}

/**
Expand All @@ -133,6 +142,9 @@ export interface ClearSessionOptions {
* **Android only:** Custom scheme to build the callback URL with.
*/
customScheme?: string;
/**
* This will use older callback URL. See {@link https://github.com/auth0/react-native-auth0/blob/master/MIGRATION_GUIDE.md#callback-url-migration} for more details.
*/
useLegacyCallbackUrl?: boolean;
}

Expand Down Expand Up @@ -541,3 +553,20 @@ export type MultifactorChallengeResponse =
| MultifactorChallengeOTPResponse
| MultifactorChallengeOOBResponse
| MultifactorChallengeOOBWithBindingResponse;

/**
* Presentation styles for when using SFSafariViewController on iOS.
* For the full description of what each option does, please see {@link https://developer.apple.com/documentation/uikit/uimodalpresentationstyle} for more details
*/
export enum SafariViewControllerPresentationStyle {
automatic = -2,
none,
fullScreen,
pageSheet,
formSheet,
currentContext,
custom,
overFullScreen,
overCurrentContext,
popover,
}
20 changes: 20 additions & 0 deletions src/webauth/__tests__/__snapshots__/webauth.spec.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,23 @@ exports[`WebAuth authorize should authorize with provided parameters 1`] = `
"tokenType": "token type",
}
`;

exports[`WebAuth authorize should set presentation style to 0 if set as empty 1`] = `
{
"accessToken": "access token",
"idToken": "id token",
"refreshToken": "refresh token",
"scope": "scope",
"tokenType": "token type",
}
`;

exports[`WebAuth authorize should set presentation style to undefined if object is empty 1`] = `
{
"accessToken": "access token",
"idToken": "id token",
"refreshToken": "refresh token",
"scope": "scope",
"tokenType": "token type",
}
`;
97 changes: 91 additions & 6 deletions src/webauth/__tests__/agent.spec.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,37 @@
jest.mock('react-native');
import * as nativeUtils from '../../utils/nativeHelper';
import Agent from '../agent';
import { NativeModules } from 'react-native';
import { NativeModules, Platform, Linking } from 'react-native';

jest.mock('react-native', () => {
// Require the original module to not be mocked...
return {
__esModule: true, // Use it when dealing with esModules
Linking: {
addEventListener: jest.fn(),
},
NativeModules: {
A0Auth0: {
webAuth: () => {},
webAuthLogout: () => {},
resumeWebAuth: () => {},
hasValidAuth0Instance: () => {},
initializeAuth0: () => {},
bundleIdentifier: 'com.my.app',
},
},
Platform: {
OS: 'ios',
},
};
});

describe('Agent', () => {
const agent = new Agent();

afterEach(() => {
jest.clearAllMocks();
});

describe('login', () => {
it('should fail if native module is not linked', async () => {
const replacedProperty = jest.replaceProperty(
Expand Down Expand Up @@ -63,13 +89,14 @@ describe('Agent', () => {
invitationUrl: 'invitationUrl',
leeway: 220,
ephemeralSession: true,
safariViewControllerPresentationStyle: 0,
additionalParameters: { test: 'test' },
}
);
expect(mock).toBeCalledWith(NativeModules.A0Auth0, clientId, domain);
expect(mockLogin).toBeCalledWith(
'test',
'test://test.com/test-os/com.my.app/callback',
'test://test.com/ios/com.my.app/callback',
'state',
'nonce',
'audience',
Expand All @@ -80,6 +107,7 @@ describe('Agent', () => {
'invitationUrl',
220,
true,
0,
{ test: 'test' }
);
});
Expand Down Expand Up @@ -133,7 +161,11 @@ describe('Agent', () => {
}
);
expect(mock).toBeCalledWith(NativeModules.A0Auth0, clientId, domain);
expect(mockLogin).toBeCalledWith('test', true, 'test://test.com/test-os/com.my.app/callback');
expect(mockLogin).toBeCalledWith(
'test',
true,
'test://test.com/ios/com.my.app/callback'
);
});
});

Expand Down Expand Up @@ -162,10 +194,63 @@ describe('Agent', () => {
});
});


describe('callbackUri', () => {
it('should return callback uri with given domain and scheme', async () => {
await expect(agent.callbackUri('domain', 'scheme')).toEqual("scheme://domain/test-os/com.test/callback");
await expect(agent.callbackUri('domain', 'scheme')).toEqual(
'scheme://domain/ios/com.test/callback'
);
});
});

describe('handle app linking for SFSafariViewController', () => {
it('with useSFSafariViewController AppLinking should be enabled', async () => {
await agent.login({}, { safariViewControllerPresentationStyle: 0 });
expect(Linking.addEventListener).toHaveBeenCalledTimes(1);
});

it('without useSFSafariViewController AppLinking should be enabled', async () => {
await agent.login({}, {});
expect(Linking.addEventListener).toHaveBeenCalledTimes(0);
});

it('for only iOS platform AppLinking should be enabled', async () => {
Platform.OS = 'android';
await agent.login({}, { safariViewControllerPresentationStyle: 0 });
expect(Linking.addEventListener).toHaveBeenCalledTimes(0);
Platform.OS = 'ios'; //reset value to ios
});

it('when login crashes and AppLinking is enabled, listener for AppLinking should be removed', async () => {
let mockSubscription = {
remove: () => {},
};
jest.spyOn(mockSubscription, 'remove').mockReturnValueOnce({});
jest
.spyOn(Linking, 'addEventListener')
.mockReturnValueOnce(mockSubscription);
jest
.spyOn(nativeUtils, '_ensureNativeModuleIsInitialized')
.mockImplementationOnce(() => {
throw Error('123123');
});
try {
await agent.login({}, { safariViewControllerPresentationStyle: 0 });
} catch (e) {}
expect(Linking.addEventListener).toHaveBeenCalledTimes(1);
expect(mockSubscription.remove).toHaveBeenCalledTimes(1);
});

it('when login crashes and AppLinking is not enabled, listener for AppLinking remove should not be called', async () => {
let mockSubscription = {
remove: () => {},
};
jest.spyOn(mockSubscription, 'remove').mockReturnValueOnce({});
jest
.spyOn(Linking, 'addEventListener')
.mockReturnValueOnce(mockSubscription);
await agent.login({}, { safariViewControllerPresentationStyle: 0 });
expect(Linking.addEventListener).toHaveBeenCalledTimes(1);
expect(mockSubscription.remove).toHaveBeenCalledTimes(0);
});
});
});
Loading