diff --git a/FAQ.md b/FAQ.md index beb8cd17..5987c814 100644 --- a/FAQ.md +++ b/FAQ.md @@ -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. @@ -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: true } // 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? @@ -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`? diff --git a/android/src/main/java/com/auth0/react/A0Auth0Module.java b/android/src/main/java/com/auth0/react/A0Auth0Module.java index fc25c7e3..0b3ee62f 100644 --- a/android/src/main/java/com/auth0/react/A0Auth0Module.java +++ b/android/src/main/java/com/auth0/react/A0Auth0Module.java @@ -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 cleanedParameters = new HashMap<>(); for (Map.Entry entry : additionalParameters.toHashMap().entrySet()) { diff --git a/example/ios/Auth0Example/AppDelegate.mm b/example/ios/Auth0Example/AppDelegate.mm index 9e33fb37..06b9eaa4 100644 --- a/example/ios/Auth0Example/AppDelegate.mm +++ b/example/ios/Auth0Example/AppDelegate.mm @@ -1,6 +1,7 @@ #import "AppDelegate.h" #import +#import @implementation AppDelegate @@ -14,6 +15,12 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( return [super application:application didFinishLaunchingWithOptions:launchOptions]; } +- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url + options:(NSDictionary *)options +{ + return [RCTLinkingManager application:app openURL:url options:options]; +} + - (NSURL *)sourceURLForBridge:(RCTBridge *)bridge { #if DEBUG diff --git a/ios/A0Auth0.m b/ios/A0Auth0.m index c62fa348..1f69821c 100644 --- a/ios/A0Auth0.m +++ b/ios/A0Auth0.m @@ -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] }; } diff --git a/ios/NativeBridge.swift b/ios/NativeBridge.swift index 47ce7a4a..7d230876 100644 --- a/ios/NativeBridge.swift +++ b/ios/NativeBridge.swift @@ -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) @@ -73,7 +73,12 @@ public class NativeBridge: NSObject { if(ephemeralSession) { let _ = builder.useEphemeralSession() } - let _ = builder.parameters(additionalParameters) + //Since we cannot have a null value here, the JS layer sends 99 if we have to ignore setting this value + 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): @@ -84,7 +89,7 @@ public class NativeBridge: NSObject { } } - + @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) { @@ -99,6 +104,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) { diff --git a/src/internal-types.ts b/src/internal-types.ts index e8c02a99..44efd088 100644 --- a/src/internal-types.ts +++ b/src/internal-types.ts @@ -82,6 +82,7 @@ export type Auth0Module = { invitationUrl?: string, leeway?: number, ephemeralSession?: boolean, + safariViewControllerPresentationStyle?: number, additionalParameters?: { [key: string]: string } ) => Promise; webAuthLogout: ( @@ -89,6 +90,7 @@ export type Auth0Module = { federated: boolean, redirectUri: string ) => Promise; + resumeWebAuth: (url: string) => Promise; saveCredentials: (credentials: Credentials) => Promise; getCredentials: ( scope?: string, @@ -133,6 +135,7 @@ export interface AgentLoginOptions { customScheme?: string; leeway?: number; ephemeralSession?: boolean; + safariViewControllerPresentationStyle?: number; additionalParameters?: { [key: string]: string }; useLegacyCallbackUrl?: boolean; } diff --git a/src/types.ts b/src/types.ts index 3e3b7cc1..a3869ef8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -110,7 +110,32 @@ 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} + * + * This can be used as a boolean value or as an object which sets the `presentationStyle`. See the examples below for reference + * + * @example + * ```typescript + * await authorize({}, {useSFSafariViewController: true}); + * ``` + * + * or + * + * @example + * ```typescript + * await authorize({}, {useSFSafariViewController: {presentationStyle: SafariViewControllerPresentationStyle.fullScreen}}); + * ``` + */ + useSFSafariViewController?: + | { + presentationStyle?: SafariViewControllerPresentationStyle; + } + | boolean; } /** @@ -133,6 +158,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; } @@ -541,3 +569,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, +} diff --git a/src/webauth/__tests__/__snapshots__/webauth.spec.js.snap b/src/webauth/__tests__/__snapshots__/webauth.spec.js.snap index 19e0df61..1d03e485 100644 --- a/src/webauth/__tests__/__snapshots__/webauth.spec.js.snap +++ b/src/webauth/__tests__/__snapshots__/webauth.spec.js.snap @@ -9,3 +9,44 @@ 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 0 if value is true 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 undefined 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 value is false 1`] = ` +{ + "accessToken": "access token", + "idToken": "id token", + "refreshToken": "refresh token", + "scope": "scope", + "tokenType": "token type", +} +`; diff --git a/src/webauth/__tests__/agent.spec.js b/src/webauth/__tests__/agent.spec.js index ea0aed9b..c4671c59 100644 --- a/src/webauth/__tests__/agent.spec.js +++ b/src/webauth/__tests__/agent.spec.js @@ -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( @@ -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', @@ -80,6 +107,7 @@ describe('Agent', () => { 'invitationUrl', 220, true, + 0, { test: 'test' } ); }); @@ -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' + ); }); }); @@ -162,10 +194,102 @@ 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 succeeds and AppLinking is enabled, listener for AppLinking subscription should be removed and resumeWebAuth should be called', async () => { + let mockSubscription = { + remove: () => {}, + }; + jest.spyOn(mockSubscription, 'remove').mockReturnValueOnce({}); + const mockEventListener = jest + .spyOn(Linking, 'addEventListener') + .mockReturnValueOnce(mockSubscription); + + jest + .spyOn(nativeUtils, '_ensureNativeModuleIsInitialized') + .mockImplementationOnce(() => {}); + + jest.spyOn(NativeModules.A0Auth0, 'webAuth').mockImplementation(() => { + mockEventListener.mock.calls[0][1]({ url: 'https://callback.url.com' }); + Promise.resolve(true); + }); + + jest + .spyOn(NativeModules.A0Auth0, 'resumeWebAuth') + .mockImplementation(() => Promise.resolve(true)); + + await agent.login({}, { safariViewControllerPresentationStyle: 0 }); + expect(Linking.addEventListener).toHaveBeenCalledTimes(1); + expect(NativeModules.A0Auth0.resumeWebAuth).toHaveBeenCalledTimes(1); + expect(mockEventListener.mock.calls[0][0]).toEqual('url'); + expect(NativeModules.A0Auth0.resumeWebAuth).toHaveBeenCalledWith( + 'https://callback.url.com' + ); + 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); + jest + .spyOn(nativeUtils, '_ensureNativeModuleIsInitialized') + .mockImplementationOnce(() => { + throw Error('123123'); + }); + try { + await agent.login({}, {}); + } catch (e) {} + expect(Linking.addEventListener).toHaveBeenCalledTimes(0); + expect(mockSubscription.remove).toHaveBeenCalledTimes(0); }); }); }); diff --git a/src/webauth/__tests__/webauth.spec.js b/src/webauth/__tests__/webauth.spec.js index b7b2c126..1603d6f4 100644 --- a/src/webauth/__tests__/webauth.spec.js +++ b/src/webauth/__tests__/webauth.spec.js @@ -30,6 +30,7 @@ describe('WebAuth', () => { leeway: 220, ephemeralSession: true, customScheme: 'scheme', + useSFSafariViewController: { presentationStyle: -2 }, }; const showMock = jest .spyOn(webauth.agent, 'login') @@ -47,7 +48,174 @@ describe('WebAuth', () => { ).resolves.toMatchSnapshot(); expect(showMock).toHaveBeenCalledWith( { clientId, domain }, - { ...parameters, ...options } + { ...parameters, ...options, safariViewControllerPresentationStyle: -2 } + ); + showMock.mockRestore(); + }); + + it('should set presentation style to 0 if set as empty', async () => { + let parameters = { + state: 'state', + nonce: 'nonce', + audience: 'audience', + scope: 'scope', + connection: 'connection', + maxAge: 120, + organization: 'org', + invitationUrl: 'invitation url', + additionalParameters: { test: 'test' }, + }; + let options = { + leeway: 220, + ephemeralSession: true, + customScheme: 'scheme', + useSFSafariViewController: {}, + }; + const showMock = jest + .spyOn(webauth.agent, 'login') + .mockImplementation(() => + Promise.resolve({ + idToken: 'id token', + accessToken: 'access token', + tokenType: 'token type', + refreshToken: 'refresh token', + scope: 'scope', + }) + ); + await expect( + webauth.authorize(parameters, options) + ).resolves.toMatchSnapshot(); + expect(showMock).toHaveBeenCalledWith( + { clientId, domain }, + { ...parameters, ...options, safariViewControllerPresentationStyle: 0 } + ); + showMock.mockRestore(); + }); + + it('should set presentation style to undefined if object is undefined', async () => { + let parameters = { + state: 'state', + nonce: 'nonce', + audience: 'audience', + scope: 'scope', + connection: 'connection', + maxAge: 120, + organization: 'org', + invitationUrl: 'invitation url', + additionalParameters: { test: 'test' }, + }; + let options = { + leeway: 220, + ephemeralSession: true, + customScheme: 'scheme', + }; + const showMock = jest + .spyOn(webauth.agent, 'login') + .mockImplementation(() => + Promise.resolve({ + idToken: 'id token', + accessToken: 'access token', + tokenType: 'token type', + refreshToken: 'refresh token', + scope: 'scope', + }) + ); + await expect( + webauth.authorize(parameters, options) + ).resolves.toMatchSnapshot(); + expect(showMock).toHaveBeenCalledWith( + { clientId, domain }, + { + ...parameters, + ...options, + safariViewControllerPresentationStyle: undefined, + } + ); + showMock.mockRestore(); + }); + + it('should set presentation style to undefined if value is false', async () => { + let parameters = { + state: 'state', + nonce: 'nonce', + audience: 'audience', + scope: 'scope', + connection: 'connection', + maxAge: 120, + organization: 'org', + invitationUrl: 'invitation url', + additionalParameters: { test: 'test' }, + }; + let options = { + leeway: 220, + ephemeralSession: true, + customScheme: 'scheme', + useSFSafariViewController: false, + }; + const showMock = jest + .spyOn(webauth.agent, 'login') + .mockImplementation(() => + Promise.resolve({ + idToken: 'id token', + accessToken: 'access token', + tokenType: 'token type', + refreshToken: 'refresh token', + scope: 'scope', + }) + ); + await expect( + webauth.authorize(parameters, options) + ).resolves.toMatchSnapshot(); + expect(showMock).toHaveBeenCalledWith( + { clientId, domain }, + { + ...parameters, + ...options, + safariViewControllerPresentationStyle: undefined, + } + ); + showMock.mockRestore(); + }); + + it('should set presentation style to 0 if value is true', async () => { + let parameters = { + state: 'state', + nonce: 'nonce', + audience: 'audience', + scope: 'scope', + connection: 'connection', + maxAge: 120, + organization: 'org', + invitationUrl: 'invitation url', + additionalParameters: { test: 'test' }, + }; + let options = { + leeway: 220, + ephemeralSession: true, + customScheme: 'scheme', + useSFSafariViewController: true, + }; + const showMock = jest + .spyOn(webauth.agent, 'login') + .mockImplementation(() => + Promise.resolve({ + idToken: 'id token', + accessToken: 'access token', + tokenType: 'token type', + refreshToken: 'refresh token', + scope: 'scope', + }) + ); + await expect( + webauth.authorize(parameters, options) + ).resolves.toMatchSnapshot(); + expect(showMock).toHaveBeenCalledWith( + { clientId, domain }, + { + ...parameters, + ...options, + safariViewControllerPresentationStyle: 0, + } ); showMock.mockRestore(); }); diff --git a/src/webauth/agent.ts b/src/webauth/agent.ts index a677b14d..d1df59ed 100644 --- a/src/webauth/agent.ts +++ b/src/webauth/agent.ts @@ -1,4 +1,9 @@ -import { NativeModules, Platform } from 'react-native'; +import { + NativeModules, + Platform, + Linking, + EmitterSubscription, +} from 'react-native'; import { Credentials } from 'src/types'; import { _ensureNativeModuleIsInitialized } from '../utils/nativeHelper'; import { @@ -14,6 +19,7 @@ class Agent { parameters: AgentParameters, options: AgentLoginOptions ): Promise { + let linkSubscription: EmitterSubscription | null = null; if (!NativeModules.A0Auth0) { return Promise.reject( new Error( @@ -21,31 +27,54 @@ class Agent { ) ); } - await _ensureNativeModuleIsInitialized( - A0Auth0, - parameters.clientId, - parameters.domain - ); - let scheme = this.getScheme( - options.useLegacyCallbackUrl ?? false, - options.customScheme - ); - let redirectUri = this.callbackUri(parameters.domain, scheme); - return A0Auth0.webAuth( - scheme, - redirectUri, - options.state, - options.nonce, - options.audience, - options.scope, - options.connection, - options.maxAge ?? 0, - options.organization, - options.invitationUrl, - options.leeway ?? 0, - options.ephemeralSession ?? false, - options.additionalParameters ?? {} - ); + return new Promise(async (resolve, reject) => { + if ( + Platform.OS === 'ios' && + options.safariViewControllerPresentationStyle !== undefined + ) { + linkSubscription = Linking.addEventListener('url', async (event) => { + try { + linkSubscription?.remove(); + await A0Auth0.resumeWebAuth(event.url); + } catch (error) { + reject(error); + } + }); + } + try { + await _ensureNativeModuleIsInitialized( + A0Auth0, + parameters.clientId, + parameters.domain + ); + let scheme = this.getScheme( + options.useLegacyCallbackUrl ?? false, + options.customScheme + ); + let redirectUri = this.callbackUri(parameters.domain, scheme); + let credentials = await A0Auth0.webAuth( + scheme, + redirectUri, + options.state, + options.nonce, + options.audience, + options.scope, + options.connection, + options.maxAge ?? 0, + options.organization, + options.invitationUrl, + options.leeway ?? 0, + options.ephemeralSession ?? false, + options.safariViewControllerPresentationStyle ?? 99, //Since we can't pass null to the native layer, and we need a value to represent this parameter is not set, we are using 99. + //The native layer will check for this and ignore if the value is 99 + options.additionalParameters ?? {} + ); + resolve(credentials); + } catch (error) { + linkSubscription?.remove(); + reject(error); + } + }); } async logout( @@ -70,7 +99,6 @@ class Agent { parameters.clientId, parameters.domain ); - return A0Auth0.webAuthLogout(scheme, federated, redirectUri); } diff --git a/src/webauth/index.ts b/src/webauth/index.ts index 8d8383b3..c4b22030 100644 --- a/src/webauth/index.ts +++ b/src/webauth/index.ts @@ -4,11 +4,13 @@ import { ClearSessionOptions, ClearSessionParameters, Credentials, + SafariViewControllerPresentationStyle, WebAuthorizeOptions, WebAuthorizeParameters, } from '../types'; import Auth from '../auth'; +import { object } from 'prop-types'; /** * Helper to perform Auth against Auth0 hosted login page @@ -36,9 +38,6 @@ class WebAuth { /** * Starts the AuthN/AuthZ transaction against the AS in the in-app browser. * - * In iOS <11 it will use `SFSafariViewController`, in iOS 11 `SFAuthenticationSession` and in iOS >11 `ASWebAuthenticationSession`. - * In Android it will use Chrome Custom Tabs. - * * To learn more about how to customize the authorize call, check the Universal Login Page * article at https://auth0.com/docs/hosted-pages/login * @@ -50,15 +49,24 @@ class WebAuth { options: WebAuthorizeOptions = {} ): Promise { const { clientId, domain, agent } = this; - return agent.login({ clientId, domain }, { ...parameters, ...options }); + let presentationStyle = options.useSFSafariViewController + ? (options.useSFSafariViewController as { presentationStyle: number }) + ?.presentationStyle ?? + SafariViewControllerPresentationStyle.fullScreen + : undefined; + return agent.login( + { clientId, domain }, + { + ...parameters, + safariViewControllerPresentationStyle: presentationStyle, + ...options, + } + ); } /** * Removes Auth0 session and optionally remove the Identity Provider session. * - * In iOS <11 it will use `SFSafariViewController`, in iOS 11 `SFAuthenticationSession` and in iOS >11 `ASWebAuthenticationSession`. - * In Android it will use Chrome Custom Tabs. - * * @see https://auth0.com/docs/logout */ clearSession(