diff --git a/Auth0.podspec b/Auth0.podspec index 490775a4..de6bc509 100644 --- a/Auth0.podspec +++ b/Auth0.podspec @@ -21,7 +21,9 @@ web_auth_files = [ 'Auth0/WebAuth.swift', 'Auth0/WebAuthentication.swift', 'Auth0/WebAuthError.swift', - 'Auth0/WebAuthUserAgent.swift' + 'Auth0/WebAuthUserAgent.swift', + 'Auth0/UIWindow+TopViewController.swift', + 'Auth0/WebViewProvider.swift' ] ios_files = ['Auth0/MobileWebAuth.swift'] diff --git a/Auth0.xcodeproj/project.pbxproj b/Auth0.xcodeproj/project.pbxproj index 820084f6..f9e5c2ca 100644 --- a/Auth0.xcodeproj/project.pbxproj +++ b/Auth0.xcodeproj/project.pbxproj @@ -321,8 +321,12 @@ A7DDDF6D2BC9A81E0077B067 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A7DDDF6B2BC9A81E0077B067 /* PrivacyInfo.xcprivacy */; }; A7DDDF6E2BC9A81E0077B067 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A7DDDF6B2BC9A81E0077B067 /* PrivacyInfo.xcprivacy */; }; A7DDDF702BC9A93F0077B067 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A7DDDF6B2BC9A81E0077B067 /* PrivacyInfo.xcprivacy */; }; + C107B51D2C9AC4D8006B6BEA /* WebViewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C107B51B2C9AC4D3006B6BEA /* WebViewProvider.swift */; }; + C107B5222CA27F7C006B6BEA /* WebViewProviderSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = C107B5202CA27F76006B6BEA /* WebViewProviderSpec.swift */; }; C12BFE432C352DD400D1CC00 /* NetworkStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = C177D76F2C2BDFE40094C657 /* NetworkStub.swift */; }; C12BFE442C352DD700D1CC00 /* StubURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C177D7742C2BE00D0094C657 /* StubURLProtocol.swift */; }; + C160EE352CABD0E5005ACE8E /* UIWindow+TopViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C160EE302CABD0DA005ACE8E /* UIWindow+TopViewController.swift */; }; + C160EE382CABD35A005ACE8E /* UIWindow+TopViewControllerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = C160EE372CABD358005ACE8E /* UIWindow+TopViewControllerSpec.swift */; }; C177D6C32C2ADDEB0094C657 /* Auth0.plist in Resources */ = {isa = PBXBuildFile; fileRef = C177D6C22C2ADDEB0094C657 /* Auth0.plist */; }; C177D6C72C2ADEB60094C657 /* CwlPreconditionTesting in Frameworks */ = {isa = PBXBuildFile; productRef = C177D6C62C2ADEB60094C657 /* CwlPreconditionTesting */; }; C177D6D42C2B0DCB0094C657 /* CwlPreconditionTesting in Frameworks */ = {isa = PBXBuildFile; productRef = C177D6D32C2B0DCB0094C657 /* CwlPreconditionTesting */; }; @@ -746,6 +750,10 @@ 5FF465BB1CE2AC4500F7ED8C /* Management.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Management.swift; path = Auth0/Management.swift; sourceTree = SOURCE_ROOT; }; 970BC36A25C27095007A7745 /* Challenge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Challenge.swift; sourceTree = ""; }; A7DDDF6B2BC9A81E0077B067 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + C107B51B2C9AC4D3006B6BEA /* WebViewProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewProvider.swift; sourceTree = ""; }; + C107B5202CA27F76006B6BEA /* WebViewProviderSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewProviderSpec.swift; sourceTree = ""; }; + C160EE302CABD0DA005ACE8E /* UIWindow+TopViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIWindow+TopViewController.swift"; sourceTree = ""; }; + C160EE372CABD358005ACE8E /* UIWindow+TopViewControllerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIWindow+TopViewControllerSpec.swift"; sourceTree = ""; }; C177D6C22C2ADDEB0094C657 /* Auth0.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Auth0.plist; sourceTree = ""; }; C177D76F2C2BDFE40094C657 /* NetworkStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkStub.swift; sourceTree = ""; }; C177D7742C2BE00D0094C657 /* StubURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLProtocol.swift; sourceTree = ""; }; @@ -928,6 +936,7 @@ 5C0AF09828330CA000162044 /* Providers */ = { isa = PBXGroup; children = ( + C107B51B2C9AC4D3006B6BEA /* WebViewProvider.swift */, 5B16D88C1F7141A0009476A5 /* ASProvider.swift */, 5C0AF09928330CBA00162044 /* SafariProvider.swift */, ); @@ -977,6 +986,7 @@ 5CF539222836DC360073F623 /* Providers */ = { isa = PBXGroup; children = ( + C107B5202CA27F76006B6BEA /* WebViewProviderSpec.swift */, 5CF5392A283835460073F623 /* ASProviderSpec.swift */, 5CF539232836DCC10073F623 /* SafariProviderSpec.swift */, ); @@ -1051,6 +1061,7 @@ 5F06DD921CC451430011842B /* Auth0Tests */ = { isa = PBXGroup; children = ( + C160EE362CABD352005ACE8E /* Extensions */, C177D76E2C2BDF9D0094C657 /* StubNetworking */, 5F28B4651D8300BB0000EB23 /* Logger */, 5FE686A81D1894990075874C /* Telemetry */, @@ -1203,6 +1214,7 @@ 5FCAB16E1D08FFE900331C84 /* Extensions */ = { isa = PBXGroup; children = ( + C160EE302CABD0DA005ACE8E /* UIWindow+TopViewController.swift */, 5C4F551923C8FB8E00C89615 /* Array+Encode.swift */, 5C4F551823C8FB8E00C89615 /* String+URLSafe.swift */, 5FCAB1721D09009600331C84 /* NSData+URLSafe.swift */, @@ -1287,6 +1299,14 @@ name = Management; sourceTree = ""; }; + C160EE362CABD352005ACE8E /* Extensions */ = { + isa = PBXGroup; + children = ( + C160EE372CABD358005ACE8E /* UIWindow+TopViewControllerSpec.swift */, + ); + path = Extensions; + sourceTree = ""; + }; C177D76E2C2BDF9D0094C657 /* StubNetworking */ = { isa = PBXGroup; children = ( @@ -2046,8 +2066,10 @@ 5F3965C21CF67CF000CDE7C0 /* WebAuth.swift in Sources */, 5FCAB1761D0900CF00331C84 /* TransactionStore.swift in Sources */, 5FDE87471D8A422300EA27DC /* Telemetry.swift in Sources */, + C107B51D2C9AC4D8006B6BEA /* WebViewProvider.swift in Sources */, 5FAE9C911D8878D400A871CE /* Auth0WebAuth.swift in Sources */, 5B5E93F91EC45C22002A37F9 /* CredentialsManagerError.swift in Sources */, + C160EE352CABD0E5005ACE8E /* UIWindow+TopViewController.swift in Sources */, 5CA541CD2B1A81A700E4284D /* Documentation.docc in Sources */, 5C41F6AA244DCAFB00252548 /* ClearSessionTransaction.swift in Sources */, 5FDE875D1D8A424700EA27DC /* AuthenticationError.swift in Sources */, @@ -2151,7 +2173,9 @@ 5FADB6091CED500900D4BB50 /* ManagementSpec.swift in Sources */, 5FCAB16D1D07AC3500331C84 /* WebAuthSpec.swift in Sources */, 5F28B4671D8300D50000EB23 /* LoggerSpec.swift in Sources */, + C107B5222CA27F7C006B6BEA /* WebViewProviderSpec.swift in Sources */, 5FBBF0431CCA90300024D2AF /* AuthenticationSpec.swift in Sources */, + C160EE382CABD35A005ACE8E /* UIWindow+TopViewControllerSpec.swift in Sources */, 5B2860D61EEF210A00C75D54 /* UserInfoSpec.swift in Sources */, 5C809D9A275FA3EF00F15A67 /* ManagementErrorSpec.swift in Sources */, 5FCAB16B1D07AC3500331C84 /* OAuth2GrantSpec.swift in Sources */, diff --git a/Auth0.xcodeproj/xcshareddata/xcschemes/Auth0.iOS.xcscheme b/Auth0.xcodeproj/xcshareddata/xcschemes/Auth0.iOS.xcscheme index 17db8ec6..b19ded2e 100644 --- a/Auth0.xcodeproj/xcshareddata/xcschemes/Auth0.iOS.xcscheme +++ b/Auth0.xcodeproj/xcshareddata/xcschemes/Auth0.iOS.xcscheme @@ -1,7 +1,7 @@ + version = "1.7"> @@ -47,6 +47,10 @@ BlueprintName = "Auth0Tests.iOS" ReferencedContainer = "container:Auth0.xcodeproj"> + + diff --git a/Auth0/Auth0WebAuth.swift b/Auth0/Auth0WebAuth.swift index 8d13cf1b..952f5b7a 100644 --- a/Auth0/Auth0WebAuth.swift +++ b/Auth0/Auth0WebAuth.swift @@ -196,8 +196,8 @@ final class Auth0WebAuth: WebAuth { state: state, organization: organization, invitation: invitation) - let provider = self.provider ?? WebAuthentication.asProvider(redirectURL: redirectURL, - ephemeralSession: ephemeralSession) + + let provider = self.provider ?? WebAuthentication.asProvider(redirectURL: redirectURL, ephemeralSession: ephemeralSession) let userAgent = provider(authorizeURL) { [storage, onCloseCallback] result in storage.clear() diff --git a/Auth0/Helpers.swift b/Auth0/Helpers.swift index b222b19a..21f0b0d0 100644 --- a/Auth0/Helpers.swift +++ b/Auth0/Helpers.swift @@ -15,3 +15,16 @@ func includeRequiredScope(in scope: String?) -> String? { guard let scope = scope, !scope.split(separator: " ").map(String.init).contains("openid") else { return scope } return "openid \(scope)" } + +func extractRedirectURL(from url: URL) -> URL? { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + return nil + } + + if let redirectURIString = components.queryItems?.first(where: { $0.name == "redirect_uri" || $0.name == "returnTo" })?.value, + let redirectURI = URL(string: redirectURIString) { + return redirectURI + } + + return nil +} \ No newline at end of file diff --git a/Auth0/SafariProvider.swift b/Auth0/SafariProvider.swift index 76616dd1..c18cf6e5 100644 --- a/Auth0/SafariProvider.swift +++ b/Auth0/SafariProvider.swift @@ -45,39 +45,6 @@ public extension WebAuthentication { } -extension SFSafariViewController { - - var topViewController: UIViewController? { - guard let root = UIApplication.shared()?.windows.last(where: \.isKeyWindow)?.rootViewController else { - return nil - } - return self.findTopViewController(from: root) - } - - func present() { - self.topViewController?.present(self, animated: true, completion: nil) - } - - private func findTopViewController(from root: UIViewController) -> UIViewController? { - if let presented = root.presentedViewController { return self.findTopViewController(from: presented) } - - switch root { - case let split as UISplitViewController: - guard let last = split.viewControllers.last else { return split } - return self.findTopViewController(from: last) - case let navigation as UINavigationController: - guard let top = navigation.topViewController else { return navigation } - return self.findTopViewController(from: top) - case let tab as UITabBarController: - guard let selected = tab.selectedViewController else { return tab } - return self.findTopViewController(from: selected) - default: - return root - } - } - -} - class SafariUserAgent: NSObject, WebAuthUserAgent { let controller: SFSafariViewController @@ -92,7 +59,7 @@ class SafariUserAgent: NSObject, WebAuthUserAgent { } func start() { - self.controller.present() + UIWindow.topViewController?.present(controller, animated: true, completion: nil) } func finish(with result: WebAuthResult) { @@ -125,7 +92,6 @@ extension SafariUserAgent: SFSafariViewControllerDelegate { // If you are developing a custom Web Auth provider, call WebAuthentication.cancel() instead // TransactionStore is internal TransactionStore.shared.cancel() - } } diff --git a/Auth0/UIWindow+TopViewController.swift b/Auth0/UIWindow+TopViewController.swift new file mode 100644 index 00000000..60c43b58 --- /dev/null +++ b/Auth0/UIWindow+TopViewController.swift @@ -0,0 +1,38 @@ +// +// UIWindow+TopViewController.swift +// Auth0 +// +// Created by Desu Sai Venkat on 01/10/24. +// Copyright © 2024 Auth0. All rights reserved. +// + +#if os(iOS) +import UIKit + +extension UIWindow { + static var topViewController: UIViewController? { + guard let root = UIApplication.shared()?.windows.last(where: \.isKeyWindow)?.rootViewController else { + return nil + } + return findTopViewController(from: root) + } + + private static func findTopViewController(from root: UIViewController) -> UIViewController? { + if let presented = root.presentedViewController { return self.findTopViewController(from: presented) } + + switch root { + case let split as UISplitViewController: + guard let last = split.viewControllers.last else { return split } + return self.findTopViewController(from: last) + case let navigation as UINavigationController: + guard let top = navigation.topViewController else { return navigation } + return self.findTopViewController(from: top) + case let tab as UITabBarController: + guard let selected = tab.selectedViewController else { return tab } + return self.findTopViewController(from: selected) + default: + return root + } + } +} +#endif diff --git a/Auth0/WebAuth.swift b/Auth0/WebAuth.swift index be81b797..9bba6b5b 100644 --- a/Auth0/WebAuth.swift +++ b/Auth0/WebAuth.swift @@ -413,6 +413,5 @@ public extension WebAuth { return try await self.clearSession(federated: federated) } #endif - } #endif diff --git a/Auth0/WebAuthError.swift b/Auth0/WebAuthError.swift index 36526f82..89ca5413 100644 --- a/Auth0/WebAuthError.swift +++ b/Auth0/WebAuthError.swift @@ -5,6 +5,7 @@ import Foundation public struct WebAuthError: Auth0Error { enum Code: Equatable { + case webViewFailure(String) case noBundleIdentifier case transactionActiveAlready case invalidInvitationURL(String) @@ -82,6 +83,7 @@ extension WebAuthError { var message: String { switch self.code { + case .webViewFailure(let webViewFailureMessage): return webViewFailureMessage case .noBundleIdentifier: return "Unable to retrieve the bundle identifier from Bundle.main.bundleIdentifier," + " or it could not be used to build a valid URL." case .transactionActiveAlready: return "Failed to start this transaction, as there is an active transaction at the" diff --git a/Auth0/WebViewProvider.swift b/Auth0/WebViewProvider.swift new file mode 100644 index 00000000..eff69da9 --- /dev/null +++ b/Auth0/WebViewProvider.swift @@ -0,0 +1,139 @@ +// +// WebViewProvider.swift +// Auth0 +// +// Created by Desu Sai Venkat on 18/09/24. +// Copyright © 2024 Auth0. All rights reserved. +// + +#if os(iOS) + +@preconcurrency import WebKit + + +/// WARNING: The use of `webViewProvider` [is not recommended](https://auth0.com/blog/oauth-2-best-practices-for-native-apps) and contravenes the guidelines of the OAuth Protocol, which advises against using web views for WebAuth. +/// The recommended approach is to utilize `ASWebAuthenticationSession`. Employ the provider below only if you fully understand the associated risks and are confident in your decision. +public extension WebAuthentication { + static func webViewProvider(style: UIModalPresentationStyle = .fullScreen) -> WebAuthProvider { + return { url, callback in + let redirectURL = extractRedirectURL(from: url)! + return WebViewUserAgent(authorizeURL: url, redirectURL: redirectURL, modalPresentationStyle: style, callback: callback) + } + } +} + +class WebViewUserAgent: NSObject, WebAuthUserAgent { + + static let customSchemeRedirectionSuccessMessage = "com.auth0.webview.redirection_success" + static let customSchemeRedirectionFailureMessage = "com.auth0.webview.redirection_failure" + let defaultSchemesSupportedByWKWebview = ["https"] + + let request: URLRequest + var webview: WKWebView! + let viewController: UIViewController + let redirectURL: URL + let callback: WebAuthProviderCallback + + + init(authorizeURL: URL, redirectURL: URL, viewController: UIViewController = UIViewController(), modalPresentationStyle: UIModalPresentationStyle = .fullScreen, callback: @escaping WebAuthProviderCallback) { + self.request = URLRequest(url: authorizeURL) + self.redirectURL = redirectURL + self.callback = callback + self.viewController = viewController + self.viewController.modalPresentationStyle = modalPresentationStyle + + super.init() + if !defaultSchemesSupportedByWKWebview.contains(redirectURL.scheme!) { + self.setupWebViewWithCustomScheme() + } else { + self.setupWebViewWithHTTPS() + } + } + + private func setupWebViewWithCustomScheme() { + let configuration = WKWebViewConfiguration() + configuration.setURLSchemeHandler(self, forURLScheme: redirectURL.scheme!) + self.webview = WKWebView(frame: .zero, configuration: configuration) + self.viewController.view = webview + webview.navigationDelegate = self + } + + private func setupWebViewWithHTTPS() { + self.webview = WKWebView(frame: .zero) + self.viewController.view = webview + webview.navigationDelegate = self + } + + func start() { + self.webview.load(self.request) + UIWindow.topViewController?.present(self.viewController, animated: true) + } + + func finish(with result: WebAuthResult) { + DispatchQueue.main.async { [weak webview, weak viewController, callback] in + webview?.removeFromSuperview() + guard let presenting = viewController?.presentingViewController else { + let error = WebAuthError(code: .unknown("Cannot dismiss WKWebView")) + return callback(.failure(error)) + } + presenting.dismiss(animated: true) { + callback(result) + } + } + } + + public override var description: String { + return String(describing: WKWebView.self) + } +} + +/// Handling Custom Scheme Callbacks +extension WebViewUserAgent: WKURLSchemeHandler { + func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) { + _ = TransactionStore.shared.resume(urlSchemeTask.request.url!) + let error = NSError(domain: WebViewUserAgent.customSchemeRedirectionSuccessMessage, code: 200, userInfo: [ + NSLocalizedDescriptionKey: "WebViewProvider: WKURLSchemeHandler: Succesfully redirected back to the app" + ]) + urlSchemeTask.didFailWithError(error) + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) { + let error = NSError(domain: WebViewUserAgent.customSchemeRedirectionFailureMessage, code: 400, userInfo: [ + NSLocalizedDescriptionKey: "WebViewProvider: WKURLSchemeHandler: Webview Resource Loading has been stopped" + ]) + urlSchemeTask.didFailWithError(error) + self.finish(with: .failure(WebAuthError(code: .webViewFailure("The WebView's resource loading was stopped.")))) + } +} + +/// Handling HTTPS Callbacks +extension WebViewUserAgent: WKNavigationDelegate { + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + if let callbackUrl = navigationAction.request.url, callbackUrl.absoluteString.starts(with: redirectURL.absoluteString), let scheme = callbackUrl.scheme, scheme == "https" { + _ = TransactionStore.shared.resume(callbackUrl) + decisionHandler(.cancel) + } else { + decisionHandler(.allow) + } + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error) { + if (error as NSError).domain == WebViewUserAgent.customSchemeRedirectionSuccessMessage { + return + } + self.finish(with: .failure(WebAuthError(code: .webViewFailure("An error occurred during a committed main frame navigation of the WebView."), cause: error))) + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: any Error) { + if (error as NSError).domain == WebViewUserAgent.customSchemeRedirectionSuccessMessage { + return + } + self.finish(with: .failure(WebAuthError(code: .webViewFailure("An error occurred while starting to load data for the main frame of the WebView."), cause: error))) + } + + func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + self.finish(with: .failure(WebAuthError(code: .webViewFailure("The WebView's content process was terminated.")))) + } +} + +#endif diff --git a/Auth0Tests/CredentialsManagerSpec.swift b/Auth0Tests/CredentialsManagerSpec.swift index 9a847ed6..33ce44d7 100644 --- a/Auth0Tests/CredentialsManagerSpec.swift +++ b/Auth0Tests/CredentialsManagerSpec.swift @@ -451,7 +451,7 @@ class CredentialsManagerSpec: QuickSpec { credentials = Credentials(accessToken: AccessToken, tokenType: TokenType, idToken: IdToken, refreshToken: RefreshToken, expiresIn: Date(timeIntervalSinceNow: -ExpiresIn)) _ = credentialsManager.store(credentials: credentials) - waitUntil(timeout: Timeout) { done in + waitUntil(timeout: .seconds(5)) { done in credentialsManager.credentials { result in expect(result).to(haveCredentialsManagerError(expectedError)) done() diff --git a/Auth0Tests/Extensions/UIWindow+TopViewControllerSpec.swift b/Auth0Tests/Extensions/UIWindow+TopViewControllerSpec.swift new file mode 100644 index 00000000..1c7371b2 --- /dev/null +++ b/Auth0Tests/Extensions/UIWindow+TopViewControllerSpec.swift @@ -0,0 +1,87 @@ +// +// Untitled.swift +// Auth0 +// +// Created by Desu Sai Venkat on 01/10/24. +// Copyright © 2024 Auth0. All rights reserved. +// + + +#if os(iOS) +import Quick +import Nimble +@testable import Auth0 + +class UIWindow_TopViewControllerSpec: QuickSpec { + + override class func spec() { + + describe("UIWindow extension") { + + var root: SpyViewController! + + beforeEach { + root = SpyViewController() + UIApplication.shared.windows.last(where: \.isKeyWindow)?.rootViewController = root + } + + it("should return nil when root is nil") { + UIApplication.shared.windows.last(where: \.isKeyWindow)?.rootViewController = nil + expect(UIWindow.topViewController).to(beNil()) + } + + it("should return root when is top controller") { + expect(UIWindow.topViewController) == root + } + + it("should return presented controller") { + let presented = UIViewController() + root.presented = presented + expect(UIWindow.topViewController) == presented + } + + it("should return split view controller if contains nothing") { + let split = UISplitViewController() + root.presented = split + expect(UIWindow.topViewController) == split + } + + it("should return last controller from split view controller") { + let split = UISplitViewController() + let last = UIViewController() + split.viewControllers = [UIViewController(), last] + root.presented = split + expect(UIWindow.topViewController) == last + } + + it("should return navigation controller if contains nothing") { + let navigation = UINavigationController() + root.presented = navigation + expect(UIWindow.topViewController) == navigation + } + + it("should return top from navigation controller") { + let top = UIViewController() + let navigation = UINavigationController(rootViewController: top) + root.presented = navigation + expect(UIWindow.topViewController) == top + } + + it("should return tab bar controller if contains nothing") { + let tabs = UITabBarController() + root.presented = tabs + expect(UIWindow.topViewController) == tabs + } + + it("should return top from tab bar controller") { + let top = UIViewController() + let tabs = UITabBarController() + tabs.viewControllers = [top] + root.presented = tabs + expect(UIWindow.topViewController) == top + } + } + + } +} +#endif diff --git a/Auth0Tests/Matchers.swift b/Auth0Tests/Matchers.swift index f53cc506..f04593e4 100644 --- a/Auth0Tests/Matchers.swift +++ b/Auth0Tests/Matchers.swift @@ -1,5 +1,5 @@ -import Foundation import Nimble +import Foundation @testable import Auth0 @@ -222,7 +222,7 @@ func beURLSafeBase64() -> Nimble.Matcher { // MARK: - Private Functions -private func beSuccessful(_ expression: Expression>, +private func beSuccessful(_ expression: Nimble.Expression>, _ message: ExpectationMessage, predicate: @escaping (T) -> Bool = { _ in true }) throws -> MatcherResult { if let actual = try expression.evaluate(), case .success(let value) = actual { @@ -231,7 +231,7 @@ private func beSuccessful(_ expression: Expression>, return MatcherResult(status: .doesNotMatch, message: message) } -private func beUnsuccessful(_ expression: Expression>, +private func beUnsuccessful(_ expression: Nimble.Expression>, _ message: ExpectationMessage, predicate: @escaping (E) -> Bool = { _ in true }) throws -> MatcherResult { if let actual = try expression.evaluate(), case .failure(let error) = actual { @@ -243,7 +243,7 @@ private func beUnsuccessful(_ expression: Expression>, private func haveCredentials(accessToken: String?, idToken: String?, refreshToken: String?, - _ expression: Expression>, + _ expression: Nimble.Expression>, _ message: ExpectationMessage) throws -> MatcherResult { if let accessToken = accessToken { _ = message.appended(message: " ") diff --git a/Auth0Tests/SafariProviderSpec.swift b/Auth0Tests/SafariProviderSpec.swift index fb376d5e..bb1ffebc 100644 --- a/Auth0Tests/SafariProviderSpec.swift +++ b/Auth0Tests/SafariProviderSpec.swift @@ -50,72 +50,6 @@ class SafariProviderSpec: QuickSpec { } - describe("SFSafariViewController extension") { - - var root: SpyViewController! - - beforeEach { - root = SpyViewController() - UIApplication.shared.windows.last(where: \.isKeyWindow)?.rootViewController = root - } - - it("should return nil when root is nil") { - UIApplication.shared.windows.last(where: \.isKeyWindow)?.rootViewController = nil - expect(safari.topViewController).to(beNil()) - } - - it("should return root when is top controller") { - expect(safari.topViewController) == root - } - - it("should return presented controller") { - let presented = UIViewController() - root.presented = presented - expect(safari.topViewController) == presented - } - - it("should return split view controller if contains nothing") { - let split = UISplitViewController() - root.presented = split - expect(safari.topViewController) == split - } - - it("should return last controller from split view controller") { - let split = UISplitViewController() - let last = UIViewController() - split.viewControllers = [UIViewController(), last] - root.presented = split - expect(safari.topViewController) == last - } - - it("should return navigation controller if contains nothing") { - let navigation = UINavigationController() - root.presented = navigation - expect(safari.topViewController) == navigation - } - - it("should return top from navigation controller") { - let top = UIViewController() - let navigation = UINavigationController(rootViewController: top) - root.presented = navigation - expect(safari.topViewController) == top - } - - it("should return tab bar controller if contains nothing") { - let tabs = UITabBarController() - root.presented = tabs - expect(safari.topViewController) == tabs - } - - it("should return top from tab bar controller") { - let top = UIViewController() - let tabs = UITabBarController() - tabs.viewControllers = [top] - root.presented = tabs - expect(safari.topViewController) == top - } - } - describe("user agent") { it("should have a custom description") { diff --git a/Auth0Tests/WebViewProviderSpec.swift b/Auth0Tests/WebViewProviderSpec.swift new file mode 100644 index 00000000..b66ce8d7 --- /dev/null +++ b/Auth0Tests/WebViewProviderSpec.swift @@ -0,0 +1,309 @@ +// WebViewProviderSpec.swift + +#if os(iOS) +import Quick +import Nimble +import WebKit +@testable import Auth0 + +private let Timeout: NimbleTimeInterval = .seconds(2) +private let LongerTimeout: NimbleTimeInterval = .seconds(5) + +class WebViewProviderSpec: QuickSpec { + + override class func spec() { + var webViewUserAgent: WebViewUserAgent! + var callback: WebAuthProviderCallback! + var mockViewController: UIViewController! + var mockWebView: WKWebView! + + let authorizeURL = URL(string: "https://auth0.com/authorize?redirect_uri=https://auth0.com/callback")! + let logoutURL = URL(string: "https://auth0.com/authorize?returnTo=https://auth0.com/callback")! + let redirectURL = URL(string: "https://auth0.com/callback")! + let customSchemeRedirectURL = URL(string: "customscheme://auth0.com/callback")! + let code = "abc123" + let customSchemeURLWithCode = URL(string: "\(customSchemeRedirectURL.absoluteString)?code=\(code)")! + + beforeEach { + callback = { result in } + mockViewController = UIViewController() + mockWebView = WKWebView() + } + + describe("WebAuthentication extension") { + it("should create a WebView provider") { + let provider = WebAuthentication.webViewProvider() + expect(provider(authorizeURL, { _ in })).to(beAKindOf(WebViewUserAgent.self)) + } + + it("should use the fullscreen presentation style by default") { + let provider = WebAuthentication.webViewProvider() + let userAgent = provider(authorizeURL, { _ in }) as! WebViewUserAgent + expect(userAgent.viewController.modalPresentationStyle) == .fullScreen + } + + it("should set a custom presentation style") { + let style = UIModalPresentationStyle.formSheet + let provider = WebAuthentication.webViewProvider(style: style) + let userAgent = provider(authorizeURL, { _ in }) as! WebViewUserAgent + expect(userAgent.viewController.modalPresentationStyle) == .formSheet + } + + it("should set the redirectURL correctly when using redirect_uri") { + let provider = WebAuthentication.webViewProvider() + let userAgent = provider(authorizeURL, { _ in }) as! WebViewUserAgent + expect(userAgent.redirectURL).to(equal(redirectURL)) + } + + it("should set the redirectURL correctly when using returnTo") { + let provider = WebAuthentication.webViewProvider() + let userAgent = provider(logoutURL, { _ in }) as! WebViewUserAgent + expect(userAgent.redirectURL).to(equal(redirectURL)) + } + } + + describe("initialization") { + it("should initialize with correct parameters") { + let authorizeURL = URL(string: "https://auth0.com/authorize?redirect_uri=https://auth0.com/callback")! + let redirectURL = URL(string: "https://auth0.com/callback")! + webViewUserAgent = WebViewUserAgent(authorizeURL: authorizeURL, redirectURL: redirectURL, viewController: mockViewController, callback: callback) + + expect(webViewUserAgent.request.url).to(equal(authorizeURL)) + expect(webViewUserAgent.redirectURL).to(equal(redirectURL)) + expect(webViewUserAgent.callback).toNot(beNil()) + expect(webViewUserAgent.viewController).to(equal(mockViewController)) + expect(webViewUserAgent.webview).toNot(beNil()) + + expect(webViewUserAgent.viewController.view).to(equal(webViewUserAgent.webview)) + expect(webViewUserAgent.webview.navigationDelegate).to(be(webViewUserAgent)) + } + + it("should initialize with custom scheme URLs and supply WKURLSchemeHandler") { + let authorizeURL = URL(string: "customscheme://auth0.com/authorize?redirect_uri=customscheme://auth0.com/callback")! + let redirectURL = URL(string: "customscheme://auth0.com/callback")! + webViewUserAgent = WebViewUserAgent(authorizeURL: authorizeURL, redirectURL: redirectURL, viewController: mockViewController, callback: callback) + + expect(webViewUserAgent.request.url).to(equal(authorizeURL)) + expect(webViewUserAgent.redirectURL).to(equal(redirectURL)) + expect(webViewUserAgent.callback).toNot(beNil()) + expect(webViewUserAgent.viewController).to(equal(mockViewController)) + expect(webViewUserAgent.webview).toNot(beNil()) + + let schemeHandler = webViewUserAgent.webview.configuration.urlSchemeHandler(forURLScheme: "customscheme") + expect(schemeHandler).toNot(beNil()) + expect(webViewUserAgent.viewController.view).to(equal(webViewUserAgent.webview)) + expect(webViewUserAgent.webview.navigationDelegate).to(be(webViewUserAgent)) + } + } + + describe("start") { + it("should present view controller and load request") { + let root = UIViewController() + UIApplication.shared.windows.last(where: \.isKeyWindow)?.rootViewController = root + webViewUserAgent = WebViewUserAgent(authorizeURL: authorizeURL, redirectURL: redirectURL, viewController: mockViewController, callback: callback) + webViewUserAgent.start() + expect(webViewUserAgent.webview.url).to(equal(authorizeURL)) + expect(root.presentedViewController).to(equal(webViewUserAgent.viewController)) + } + + it("should present view controller and load request with custom scheme URLs") { + let root = UIViewController() + UIApplication.shared.windows.last(where: \.isKeyWindow)?.rootViewController = root + webViewUserAgent = WebViewUserAgent(authorizeURL: authorizeURL, redirectURL: customSchemeRedirectURL, viewController: mockViewController, callback: callback) + webViewUserAgent.start() + expect(webViewUserAgent.webview.url).to(equal(authorizeURL)) + expect(root.presentedViewController).to(equal(webViewUserAgent.viewController)) + } + } + + describe("finish") { + it("should dismiss view controller, remove webview, and call callback with success result") { + let root = UIViewController() + UIApplication.shared.windows.last(where: \.isKeyWindow)?.rootViewController = root + root.present(mockViewController, animated: false) + + waitUntil(timeout: LongerTimeout) { done in + callback = { result in + expect(root.presentedViewController).to(beNil()) + expect(mockViewController.view.subviews.contains(webViewUserAgent.webview)).to(beFalse()) + expect(result).to(beSuccessful()) + done() + } + + webViewUserAgent = WebViewUserAgent(authorizeURL: authorizeURL, redirectURL: redirectURL, viewController: mockViewController, callback: callback) + webViewUserAgent.finish(with: .success(())) + } + } + + it("should dismiss view controller, remove webview, and call callback with failure result") { + let root = UIViewController() + UIApplication.shared.windows.last(where: \.isKeyWindow)?.rootViewController = root + root.present(mockViewController, animated: false) + + waitUntil(timeout: Timeout) { done in + callback = { result in + expect(root.presentedViewController).to(beNil()) + expect(mockViewController.view.subviews.contains(webViewUserAgent.webview)).to(beFalse()) + expect(result).to(haveWebAuthError(WebAuthError(code: .webViewFailure("An error occurred while starting to load data for the main frame of the WebView.")))) + done() + } + + webViewUserAgent = WebViewUserAgent(authorizeURL: authorizeURL, redirectURL: redirectURL, viewController: mockViewController, callback: callback) + webViewUserAgent.finish(with: .failure(WebAuthError(code: .webViewFailure("An error occurred while starting to load data for the main frame of the WebView.")))) + } + } + + it("should call the callback with an error when the view controller holding webview cannot be dismissed") { + let error = WebAuthError(code: .unknown("Cannot dismiss WKWebView")) + waitUntil(timeout: Timeout) { done in + callback = { result in + expect(mockViewController.view.subviews.contains(webViewUserAgent.webview)).to(beFalse()) + expect(result).to(haveWebAuthError(error)) + done() + } + + webViewUserAgent = WebViewUserAgent(authorizeURL: authorizeURL, redirectURL: redirectURL, viewController: mockViewController, callback: callback) + webViewUserAgent.finish(with: .failure(WebAuthError(code: .webViewFailure("An error occurred while starting to load data for the main frame of the WebView.")))) + } + } + } + + describe("WKURLSchemeHandler") { + it("should handle custom scheme callbacks correctly") { + webViewUserAgent = WebViewUserAgent(authorizeURL: authorizeURL, redirectURL: customSchemeRedirectURL, viewController: mockViewController, callback: callback) + let mockCustomSchemeTask = MockURLSchemeTask(request: URLRequest(url: customSchemeURLWithCode)) + webViewUserAgent.webView(mockWebView, start: mockCustomSchemeTask) + + expect(mockCustomSchemeTask.didFailWithErrorCalled).to(beTrue()) + expect((mockCustomSchemeTask.error! as NSError).domain).to(equal(WebViewUserAgent.customSchemeRedirectionSuccessMessage)) + expect((mockCustomSchemeTask.error! as NSError).code).to(equal(200)) + expect((mockCustomSchemeTask.error! as NSError).localizedDescription).to(equal("WebViewProvider: WKURLSchemeHandler: Succesfully redirected back to the app")) + } + + it("should handle custom scheme callbacks correctly when resource loading is stopped") { + let root = UIViewController() + UIApplication.shared.windows.last(where: \.isKeyWindow)?.rootViewController = root + root.present(mockViewController, animated: false) + + waitUntil(timeout: Timeout) { done in + callback = { result in + expect(result).to(haveWebAuthError(WebAuthError(code: .webViewFailure("The WebView's resource loading was stopped.")))) + done() + } + webViewUserAgent = WebViewUserAgent(authorizeURL: authorizeURL, redirectURL: customSchemeRedirectURL, viewController: mockViewController, callback: callback) + let mockCustomSchemeTask = MockURLSchemeTask(request: URLRequest(url: customSchemeRedirectURL)) + webViewUserAgent.webView(mockWebView, stop: mockCustomSchemeTask) + expect(mockCustomSchemeTask.didFailWithErrorCalled).to(beTrue()) + expect((mockCustomSchemeTask.error! as NSError).domain).to(equal(WebViewUserAgent.customSchemeRedirectionFailureMessage)) + } + } + } + + describe("WKNavigationDelegate") { + + beforeEach { + let root = UIViewController() + UIApplication.shared.windows.last(where: \.isKeyWindow)?.rootViewController = root + root.present(mockViewController, animated: false) + } + + it("should handle navigation actions correctly when a valid redirect URL is passed") { + webViewUserAgent = WebViewUserAgent(authorizeURL: authorizeURL, redirectURL: redirectURL, viewController: mockViewController, callback: callback) + + let navigationAction = MockWKNavigationAction(url: redirectURL) + var decisionHandlerCalled = false + webViewUserAgent.webView(mockWebView, decidePolicyFor: navigationAction) { policy in + expect(policy).to(equal(.cancel)) + decisionHandlerCalled = true + } + expect(decisionHandlerCalled).to(beTrue()) + } + + it("should handle navigation actions correctly when a invalid redirect URL is passed") { + webViewUserAgent = WebViewUserAgent(authorizeURL: authorizeURL, redirectURL: redirectURL, viewController: mockViewController, callback: callback) + + let navigationAction = MockWKNavigationAction(url: URL(string:"https://okta.com/callback")!) + var decisionHandlerCalled = false + webViewUserAgent.webView(mockWebView, decidePolicyFor: navigationAction) { policy in + expect(policy).to(equal(.allow)) + decisionHandlerCalled = true + } + expect(decisionHandlerCalled).to(beTrue()) + } + + it("should handle navigation failures correctly when an error during main frame navigation commiting") { + waitUntil(timeout: Timeout) { done in + let error = NSError(domain: "WKWebViewNavigationFailure", code: 400) + callback = { result in + expect(result).to(haveWebAuthError(WebAuthError(code: .webViewFailure("An error occurred during a committed main frame navigation of the WebView."), cause: error))) + done() + } + webViewUserAgent = WebViewUserAgent(authorizeURL: authorizeURL, redirectURL: redirectURL, viewController: mockViewController, callback: callback) + webViewUserAgent.webView(mockWebView, didFail: nil, withError: error) + } + } + + it("should handle navigation failures correctly when starting to load data for main frame") { + waitUntil(timeout: Timeout) { done in + let error = NSError(domain: "WKWebViewNavigationFailure", code: 400) + callback = { result in + expect(result).to(haveWebAuthError(WebAuthError(code: .webViewFailure("An error occurred while starting to load data for the main frame of the WebView."), cause: error))) + done() + } + webViewUserAgent = WebViewUserAgent(authorizeURL: authorizeURL, redirectURL: redirectURL, viewController: mockViewController, callback: callback) + webViewUserAgent.webView(mockWebView, didFailProvisionalNavigation: nil, withError: error) + } + } + + it("should handle webview failures correctly when content process terminated") { + waitUntil(timeout: Timeout) { done in + callback = { result in + expect(result).to(haveWebAuthError(WebAuthError(code: .webViewFailure("The WebView's content process was terminated.")))) + done() + } + webViewUserAgent = WebViewUserAgent(authorizeURL: authorizeURL, redirectURL: redirectURL, viewController: mockViewController, callback: callback) + webViewUserAgent.webViewWebContentProcessDidTerminate(mockWebView) + } + } + } + } +} + +class MockURLSchemeTask: NSObject, WKURLSchemeTask { + var didFailWithErrorCalled = false + var error: Error? + var request: URLRequest + + init(request: URLRequest) { + self.request = request + } + + func didReceive(_ response: URLResponse) { + // Mock implementation + } + + func didReceive(_ data: Data) { + // Mock implementation + } + + func didFinish() { + // Mock implementation + } + + func didFailWithError(_ error: Error) { + didFailWithErrorCalled = true + self.error = error + } +} + +class MockWKNavigationAction: WKNavigationAction { + var mockRequest: URLRequest + init(url: URL) { + self.mockRequest = URLRequest(url: url) + } + override var request: URLRequest { + return mockRequest + } +} + +#endif