diff --git a/Samples/EmbeddedAuthWithSDKs/EmbeddedAuth/Base.lproj/Main.storyboard b/Samples/EmbeddedAuthWithSDKs/EmbeddedAuth/Base.lproj/Main.storyboard index de2c5237..24bfe696 100644 --- a/Samples/EmbeddedAuthWithSDKs/EmbeddedAuth/Base.lproj/Main.storyboard +++ b/Samples/EmbeddedAuthWithSDKs/EmbeddedAuth/Base.lproj/Main.storyboard @@ -35,7 +35,7 @@ - + @@ -113,6 +113,25 @@ + + + + + + + + + + + + + + @@ -141,6 +160,7 @@ + diff --git a/Samples/EmbeddedAuthWithSDKs/EmbeddedAuth/ClientConfiguration.swift b/Samples/EmbeddedAuthWithSDKs/EmbeddedAuth/ClientConfiguration.swift index 465c49ea..ee0d39d2 100644 --- a/Samples/EmbeddedAuthWithSDKs/EmbeddedAuth/ClientConfiguration.swift +++ b/Samples/EmbeddedAuthWithSDKs/EmbeddedAuth/ClientConfiguration.swift @@ -16,15 +16,17 @@ struct ClientConfiguration { private static let issuerKey = "issuerUrl" private static let clientIdKey = "clientId" private static let redirectUriKey = "redirectUrl" + private static let recoveryTokenKey = "recoveryToken" private static let scopesKey = "scopes" let clientId: String let issuer: String let redirectUri: String let scopes: String + let recoveryToken: String? let shouldSave: Bool - init(clientId: String, issuer: String, redirectUri: String, scopes: String, shouldSave: Bool) throws { + init(clientId: String, issuer: String, redirectUri: String, scopes: String, recoveryToken: String?, shouldSave: Bool) throws { guard let issuerUrl = URL(string: issuer) else { throw ConfigurationError.invalidUrl(name: "issuer") } @@ -58,6 +60,7 @@ struct ClientConfiguration { self.clientId = clientId self.scopes = scopes self.redirectUri = redirectUri + self.recoveryToken = recoveryToken self.shouldSave = shouldSave } @@ -66,13 +69,15 @@ struct ClientConfiguration { "--issuer", "-i", "--redirectUri", "-r", "--scopes", "-s", - "--clientId", "-c" + "--clientId", "-c", + "--recoveryToken", "-t" ] var issuer: String? var clientId: String? var scopes: String = "openid profile offline_access" var redirectUri: String? + var recoveryToken: String? var key: String? for argument in CommandLine.arguments { if arguments.contains(argument) { @@ -93,6 +98,9 @@ struct ClientConfiguration { case "--scopes", "-s": scopes = argument + case "--recoveryToken", "-t": + recoveryToken = argument + default: break } key = nil @@ -105,6 +113,7 @@ struct ClientConfiguration { issuer: issuer!, redirectUri: redirectUri!, scopes: scopes, + recoveryToken: recoveryToken, shouldSave: false) } @@ -127,6 +136,7 @@ struct ClientConfiguration { issuer: issuer, redirectUri: redirectUri, scopes: scopes, + recoveryToken: nil, shouldSave: false) } @@ -145,10 +155,16 @@ struct ClientConfiguration { return nil } + var recoveryToken = defaults.string(forKey: recoveryTokenKey) ?? environment["RECOVERY_TOKEN"] + if recoveryToken?.count == 0 { + recoveryToken = nil + } + return try? ClientConfiguration(clientId: clientId, issuer: issuer, redirectUri: redirectUri, scopes: scopes, + recoveryToken: recoveryToken, shouldSave: false) } @@ -163,6 +179,7 @@ struct ClientConfiguration { defaults.setValue(issuer, forKey: type(of: self).issuerKey) defaults.setValue(clientId, forKey: type(of: self).clientIdKey) defaults.setValue(redirectUri, forKey: type(of: self).redirectUriKey) + defaults.setValue(recoveryToken, forKey: type(of: self).recoveryTokenKey) defaults.setValue(scopes, forKey: type(of: self).scopesKey) defaults.synchronize() diff --git a/Samples/EmbeddedAuthWithSDKs/EmbeddedAuth/Signin/View Controllers/IDXStartViewController.swift b/Samples/EmbeddedAuthWithSDKs/EmbeddedAuth/Signin/View Controllers/IDXStartViewController.swift index b8ce6f42..1b5b0b95 100644 --- a/Samples/EmbeddedAuthWithSDKs/EmbeddedAuth/Signin/View Controllers/IDXStartViewController.swift +++ b/Samples/EmbeddedAuthWithSDKs/EmbeddedAuth/Signin/View Controllers/IDXStartViewController.swift @@ -27,27 +27,31 @@ class IDXStartViewController: UIViewController, IDXSigninController { return } - IDXClient.start(with: signin.configuration) { (client, error) in - guard let client = client else { - if let error = error { - self.showError(error) - - signin.failure(with: error) - } - return - } - - self.signin?.idx = client - client.resume { (response, error) in - guard let response = response else { - if let error = error { - self.showError(error) - - signin.failure(with: error) + var options = [IDXClient.Option:String]() + if let recoveryToken = ClientConfiguration.active?.recoveryToken { + options[.recoveryToken] = recoveryToken + } + + IDXClient.start(with: signin.configuration, options: options) { result in + switch result { + case .success(let client): + self.signin?.idx = client + client.resume { (response, error) in + guard let response = response else { + if let error = error { + self.showError(error) + + signin.failure(with: error) + } + return } - return + signin.proceed(to: response) } - signin.proceed(to: response) + + case .failure(let error): + self.showError(error) + + signin.failure(with: error) } } } diff --git a/Samples/EmbeddedAuthWithSDKs/EmbeddedAuth/View Controllers/ClientConfigurationViewController.swift b/Samples/EmbeddedAuthWithSDKs/EmbeddedAuth/View Controllers/ClientConfigurationViewController.swift index 619743e8..eaabe86d 100644 --- a/Samples/EmbeddedAuthWithSDKs/EmbeddedAuth/View Controllers/ClientConfigurationViewController.swift +++ b/Samples/EmbeddedAuthWithSDKs/EmbeddedAuth/View Controllers/ClientConfigurationViewController.swift @@ -29,6 +29,7 @@ class ClientConfigurationViewController: UIViewController { @IBOutlet weak var clientIdField: UITextField! @IBOutlet weak var scopesField: UITextField! @IBOutlet weak var redirectField: UITextField! + @IBOutlet weak var recoveryTokenField: UITextField! var configuration: ClientConfiguration? = ClientConfiguration.active override func viewDidLoad() { @@ -38,12 +39,14 @@ class ClientConfigurationViewController: UIViewController { clientIdField.text = configuration?.clientId scopesField.text = configuration?.scopes redirectField.text = configuration?.redirectUri - + recoveryTokenField.text = configuration?.recoveryToken + issuerField.accessibilityIdentifier = "issuerField" clientIdField.accessibilityIdentifier = "clientIdField" scopesField.accessibilityIdentifier = "scopesField" redirectField.accessibilityIdentifier = "redirectField" - + recoveryTokenField.accessibilityIdentifier = "recoveryTokenField" + view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(backgroundTapped))) } @@ -70,6 +73,7 @@ class ClientConfigurationViewController: UIViewController { issuer: issuerUrl, redirectUri: redirectUri, scopes: scopes, + recoveryToken: recoveryTokenField.text, shouldSave: true) configuration?.save() dismiss(animated: true) @@ -97,8 +101,10 @@ extension ClientConfigurationViewController: UITextFieldDelegate { case scopesField: redirectField.becomeFirstResponder() case redirectField: - redirectField.resignFirstResponder() - + recoveryTokenField.becomeFirstResponder() + case recoveryTokenField: + recoveryTokenField.resignFirstResponder() + default: break } return false diff --git a/Sources/OktaIdx/IDXClient.swift b/Sources/OktaIdx/IDXClient.swift index 18d2253c..9c663f7e 100644 --- a/Sources/OktaIdx/IDXClient.swift +++ b/Sources/OktaIdx/IDXClient.swift @@ -17,6 +17,14 @@ import Foundation /// The `IDXClient.Configuration` class is used to communicate which application, defined within Okta, the user is being authenticated with. From this point a workflow is initiated, consisting of a series of authentication "Remediation" steps. At each step, your application can introspect the `Response` object to determine which UI should be presented to your user to guide them through to login. @objc public final class IDXClient: NSObject { + /// Options to use when initiating an IDXClient. + public enum Option: String { + /// Option used when a client needs to supply its own custom state value when initiating an IDXClient. + case state + + /// Option used when a user is authenticating using a recovery token. + case recoveryToken = "recovery_token" + } /// The type used for the completion handler result from any method that returns an `Response`. /// - Parameters: @@ -43,26 +51,31 @@ public final class IDXClient: NSObject { /// Starts a new authentication session using the given configuration values. If the client is able to successfully interact with Okta Identity Engine, a new client instance is returned to the caller. /// - Parameters: /// - configuration: Configuration describing the app settings to contact. - /// - state: Optional state string to use within the OAuth2 transaction. + /// - options: Options to include within the OAuth2 transaction. /// - completion: Completion block to be invoked when a client is created, or when an error is received. public static func start(with configuration: Configuration, - state: String? = nil, + options: [Option:String]? = nil, completion: @escaping (Result) -> Void) { let api = Version.latest.clientImplementation(with: configuration) - start(with: api, state: state, completion: completion) + start(with: api, options: options, completion: completion) } /// Starts a new authentication session using the given configuration values. If the client is able to successfully interact with Okta Identity Engine, a new client instance is returned to the caller. /// - Parameters: /// - configuration: Configuration describing the app settings to contact. - /// - state: Optional state string to use within the OAuth2 transaction. + /// - options: Options to include within the OAuth2 transaction. /// - completion: Completion block to be invoked when a client is created, or when an error is received. @objc public static func start(with configuration: Configuration, - state: String? = nil, + options: [String:String]? = nil, completion: @escaping (_ client: IDXClient?, _ error: Error?) -> Void) { - start(with: configuration, state: state) { result in + let mappedOptions = options?.reduce(into: [Option:String](), { partialResult, item in + guard let option = Option(rawValue: item.key) else { return } + partialResult[option] = item.value + }) + + start(with: configuration, options: mappedOptions) { result in switch result { case .failure(let error): completion(nil, error) @@ -73,10 +86,10 @@ public final class IDXClient: NSObject { } static func start(with api: IDXClientAPIImpl, - state: String? = nil, + options: [Option:String]? = nil, completion: @escaping (Result) -> Void) { - api.start(state: state) { result in + api.start(options: options) { result in switch result { case .failure(let error): completion(.failure(error)) @@ -206,20 +219,20 @@ extension IDXClient { /// Starts a new authentication session using the given configuration values. If the client is able to successfully interact with Okta Identity Engine, a new client instance is returned to the caller. /// - Parameters: /// - configuration: Configuration describing the app settings to contact. - /// - state: Optional state string to use within the OAuth2 transaction. + /// - options: Options to include within the OAuth2 transaction. /// - Returns: An IDXClient instance for this session. public static func start(with configuration: Configuration, - state: String? = nil) async throws -> IDXClient + options: [Option:String]? = nil) async throws -> IDXClient { let api = Version.latest.clientImplementation(with: configuration) - return try await start(with: api, state: state) + return try await start(with: api, options: options) } static func start(with api: IDXClientAPIImpl, - state: String? = nil) async throws -> IDXClient + options: [Option:String]? = nil) async throws -> IDXClient { try await withCheckedThrowingContinuation { continuation in - start(with: api, state: state) { result in + start(with: api, options: options) { result in continuation.resume(with: result) } } diff --git a/Sources/OktaIdx/Internal/Implementations/IDXClientAPIImpl.swift b/Sources/OktaIdx/Internal/Implementations/IDXClientAPIImpl.swift index 84a94ce8..97571a3e 100644 --- a/Sources/OktaIdx/Internal/Implementations/IDXClientAPIImpl.swift +++ b/Sources/OktaIdx/Internal/Implementations/IDXClientAPIImpl.swift @@ -39,7 +39,7 @@ protocol IDXClientAPIImpl: AnyObject { /// The upstream client to communicate critical events to var client: IDXClientAPI? { get set } - func start(state: String?, completion: @escaping (Result) -> Void) + func start(options: [IDXClient.Option:String]?, completion: @escaping (Result) -> Void) func resume(completion: @escaping (Result) -> Void) func proceed(remediation option: Remediation, completion: @escaping (Result) -> Void) diff --git a/Sources/OktaIdx/Internal/Implementations/Version1/IDXClientAPIv1.swift b/Sources/OktaIdx/Internal/Implementations/Version1/IDXClientAPIv1.swift index f11a4e74..89a9a955 100644 --- a/Sources/OktaIdx/Internal/Implementations/Version1/IDXClientAPIv1.swift +++ b/Sources/OktaIdx/Internal/Implementations/Version1/IDXClientAPIv1.swift @@ -34,22 +34,31 @@ extension IDXClient { } extension IDXClient.APIVersion1: IDXClientAPIImpl { - func start(state: String?, completion: @escaping (Result) -> Void) { + func start(options: [IDXClient.Option : String]?, completion: @escaping (Result) -> Void) { guard let codeVerifier = String.pkceCodeVerifier(), let codeChallenge = codeVerifier.pkceCodeChallenge() else { completion(.failure(.internalMessage("Cannot create a PKCE Code Verifier"))) return } + + // Ensure we have, at minimum, a state value + let state = options?[.state] ?? UUID().uuidString + var options = options ?? [:] + options[.state] = state + + let mappedOptions = options.reduce(into: [String:String](), { partialResult, item in + partialResult[item.key.rawValue] = item.value + }) - let request = InteractRequest(state: state, codeChallenge: codeChallenge) + let request = InteractRequest(options: mappedOptions, codeChallenge: codeChallenge) request.send(to: session, using: configuration) { result in switch result { case .failure(let error): completion(.failure(error)) case .success(let response): completion(.success(IDXClient.Context(configuration: self.configuration, - state: request.state, + state: state, interactionHandle: response.interactionHandle, codeVerifier: codeVerifier))) } diff --git a/Sources/OktaIdx/Internal/Implementations/Version1/Requests/InteractRequest.swift b/Sources/OktaIdx/Internal/Implementations/Version1/Requests/InteractRequest.swift index 63c59e25..e0126ab9 100644 --- a/Sources/OktaIdx/Internal/Implementations/Version1/Requests/InteractRequest.swift +++ b/Sources/OktaIdx/Internal/Implementations/Version1/Requests/InteractRequest.swift @@ -17,22 +17,15 @@ extension IDXClient.APIVersion1.InteractRequest: IDXClientAPIRequest { typealias ResponseType = Response - init(state: String?, codeChallenge: String) { - self.state = state ?? UUID().uuidString - self.codeChallenge = codeChallenge - } - func urlRequest(using configuration:IDXClient.Configuration) -> URLRequest? { guard let url = configuration.issuerUrl(with: "v1/interact") else { return nil } - let params = [ - "client_id": configuration.clientId, - "scope": configuration.scopes.joined(separator: " "), - "code_challenge": codeChallenge, - "code_challenge_method": "S256", - "redirect_uri": configuration.redirectUri, - "state": state - ] + var params = options + params["client_id"] = configuration.clientId + params["scope"] = configuration.scopes.joined(separator: " ") + params["code_challenge"] = codeChallenge + params["code_challenge_method"] = "S256" + params["redirect_uri"] = configuration.redirectUri var request = URLRequest(url: url) request.httpMethod = "POST" diff --git a/Sources/OktaIdx/Internal/Implementations/Version1/Responses/Responses.swift b/Sources/OktaIdx/Internal/Implementations/Version1/Responses/Responses.swift index 415f397e..44c71081 100644 --- a/Sources/OktaIdx/Internal/Implementations/Version1/Responses/Responses.swift +++ b/Sources/OktaIdx/Internal/Implementations/Version1/Responses/Responses.swift @@ -45,7 +45,7 @@ extension IDXClient.APIVersion1 { } struct InteractRequest: HasOAuthHTTPHeaders { - let state: String + let options: [String:String] let codeChallenge: String } diff --git a/Tests/OktaIdxTests/IDXClientAPIVersion1Tests.swift b/Tests/OktaIdxTests/IDXClientAPIVersion1Tests.swift index bc0244df..d0dd0c81 100644 --- a/Tests/OktaIdxTests/IDXClientAPIVersion1Tests.swift +++ b/Tests/OktaIdxTests/IDXClientAPIVersion1Tests.swift @@ -41,10 +41,14 @@ class IDXClientAPIVersion1Tests: XCTestCase { try session.expect("https://foo.oktapreview.com/oauth2/default/v1/interact", fileName: "interact-response") let completion = expectation(description: "Response") - api.start(state: nil) { result in + api.start(options: nil) { result in if case let Result.success(context) = result { XCTAssertNotNil(context) XCTAssertEqual(context.interactionHandle, "003Q14X7li") + + // Ensure state is a UUID + let state = context.state + XCTAssertNotNil(UUID(uuidString: state)) } else { XCTFail("Not successful") } @@ -53,13 +57,46 @@ class IDXClientAPIVersion1Tests: XCTestCase { wait(for: [completion], timeout: 1) } + func testInteractWithOptions() throws { + try session.expect("https://foo.oktapreview.com/oauth2/default/v1/interact", fileName: "interact-response") + + let completion = expectation(description: "Response") + api.start(options: [.state: "MyState", .recoveryToken: "someRecoveryToken"]) { result in + if case let Result.success(context) = result { + XCTAssertNotNil(context) + XCTAssertEqual(context.state, "MyState") + } else { + XCTFail("Not successful") + } + completion.fulfill() + } + wait(for: [completion], timeout: 1) + + let request = try XCTUnwrap(session.requests.last) + + XCTAssertEqual(request.url?.absoluteString, + "https://foo.oktapreview.com/oauth2/default/v1/interact") + + let data = try XCTUnwrap(request.httpBody) + let body = String(data: data, encoding: .utf8)? + .components(separatedBy: "&") + .reduce(into: [String:String](), { partialResult, item in + let items = item.components(separatedBy: "=") + guard items.count == 2 else { return } + partialResult[items[0]] = items[1] + }) + + XCTAssertEqual(body?["state"], "MyState") + XCTAssertEqual(body?["recovery_token"], "someRecoveryToken") + } + func testInteractFailure() throws { try session.expect("https://foo.oktapreview.com/v1/interact", fileName: "interact-error-response", statusCode: 400) let completion = expectation(description: "Response") - api.start(state: nil) { result in + api.start(options: nil) { result in if case let Result.failure(error) = result { XCTAssertEqual(error, .invalidResponseData) } else { diff --git a/Tests/OktaIdxTests/IDXClientRequestTests.swift b/Tests/OktaIdxTests/IDXClientRequestTests.swift index 370e29ef..bbce9ce4 100644 --- a/Tests/OktaIdxTests/IDXClientRequestTests.swift +++ b/Tests/OktaIdxTests/IDXClientRequestTests.swift @@ -25,7 +25,7 @@ class IDXClientRequestTests: XCTestCase { redirectUri: "redirect:/uri") func testInteractRequest() throws { - let request = IDXClient.APIVersion1.InteractRequest(state: nil, codeChallenge: "ABCEasyas123") + let request = IDXClient.APIVersion1.InteractRequest(options: ["state": "state"], codeChallenge: "ABCEasyas123") let urlRequest = request.urlRequest(using: configuration) XCTAssertNotNil(urlRequest) @@ -46,14 +46,11 @@ class IDXClientRequestTests: XCTestCase { XCTAssertEqual(data?["code_challenge"], "ABCEasyas123") XCTAssertEqual(data?["code_challenge_method"], "S256") XCTAssertEqual(data?["redirect_uri"], "redirect:/uri") - - // Ensure state is a UUID - let state = data?["state"] - XCTAssertNotNil(UUID(uuidString: state!!)) + XCTAssertEqual(data?["state"], "state") } func testInteractRequestWithCustomState() throws { - let request = IDXClient.APIVersion1.InteractRequest(state: "mystate", codeChallenge: "ABCEasyas123") + let request = IDXClient.APIVersion1.InteractRequest(options: ["state": "mystate"], codeChallenge: "ABCEasyas123") let urlRequest = try XCTUnwrap(request.urlRequest(using: configuration)) let data = try XCTUnwrap(urlRequest.httpBody?.urlFormEncoded()) XCTAssertEqual(data["state"], "mystate") diff --git a/Tests/OktaIdxTests/IDXClientTests.swift b/Tests/OktaIdxTests/IDXClientTests.swift index fc009376..ca891914 100644 --- a/Tests/OktaIdxTests/IDXClientTests.swift +++ b/Tests/OktaIdxTests/IDXClientTests.swift @@ -78,16 +78,16 @@ class IDXClientTests: XCTestCase { // start() expect = expectation(description: "start") - IDXClient.start(with: api, state: "state") { result in + IDXClient.start(with: api, options: [.state: "stateString"]) { result in called = true expect.fulfill() } wait(for: [ expect ], timeout: 1) XCTAssertTrue(called) call = api.recordedCalls.last - XCTAssertEqual(call?.function, "start(state:completion:)") + XCTAssertEqual(call?.function, "start(options:completion:)") XCTAssertEqual(call?.arguments?.count, 1) - XCTAssertEqual(call?.arguments?["state"] as! String, "state") + XCTAssertEqual(call?.arguments?["options"] as! [IDXClient.Option:String], [.state: "stateString"]) api.reset() // resume() diff --git a/Tests/OktaIdxTests/UserAgentTests.swift b/Tests/OktaIdxTests/UserAgentTests.swift index 22c2330e..1a1ab5e3 100644 --- a/Tests/OktaIdxTests/UserAgentTests.swift +++ b/Tests/OktaIdxTests/UserAgentTests.swift @@ -38,7 +38,7 @@ class UserAgentTests: XCTestCase { } func testInteractRequest() { - let request = IDXClient.APIVersion1.InteractRequest(state: nil, codeChallenge: "challenge") + let request = IDXClient.APIVersion1.InteractRequest(options: [:], codeChallenge: "challenge") let userAgent = request.httpHeaders["User-Agent"] XCTAssertNotNil(userAgent) diff --git a/Tests/TestCommon/Mocks/IDXClientAPIMock.swift b/Tests/TestCommon/Mocks/IDXClientAPIMock.swift index 090473c2..4a234497 100644 --- a/Tests/TestCommon/Mocks/IDXClientAPIMock.swift +++ b/Tests/TestCommon/Mocks/IDXClientAPIMock.swift @@ -100,10 +100,10 @@ class IDXClientAPIv1Mock: MockBase, IDXClientAPIImpl { self.configuration = configuration } - func start(state: String?, completion: @escaping (Result) -> Void) { + func start(options: [IDXClient.Option: String]?, completion: @escaping (Result) -> Void) { recordedCalls.append(RecordedCall(function: #function, arguments: [ - "state": state as Any + "options": options as Any ])) completion(result(for: #function)) } diff --git a/Tests/TestCommon/Mocks/URLSessionMock.swift b/Tests/TestCommon/Mocks/URLSessionMock.swift index 5ea7e325..ee48dec2 100644 --- a/Tests/TestCommon/Mocks/URLSessionMock.swift +++ b/Tests/TestCommon/Mocks/URLSessionMock.swift @@ -38,6 +38,8 @@ class URLSessionMock: URLSessionProtocol { let error: Error? } + private(set) var requests: [URLRequest] = [] + private var calls: [String: Call] = [:] func expect(_ url: String, call: Call) { calls[url] = call @@ -81,6 +83,7 @@ class URLSessionMock: URLSessionProtocol { } func dataTaskWithRequest(with request: URLRequest, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol { + requests.append(request) let response = call(for: request.url!.absoluteString) return URLSessionDataTaskMock(data: response?.data, response: response?.response,