Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Introduce generic support for supplying a recovery token when initiating an IDXClient #87

Merged
merged 3 commits into from
Jan 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" distribution="fillEqually" spacing="12" translatesAutoresizingMaskIntoConstraints="NO" id="4WF-jK-s7Q">
<rect key="frame" x="8" y="96" width="398" height="286"/>
<rect key="frame" x="8" y="96" width="398" height="352.5"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="5i7-kD-vNI">
<rect key="frame" x="16" y="16" width="366" height="54.5"/>
Expand Down Expand Up @@ -113,6 +113,25 @@
</textField>
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="yhO-zL-Yr1">
<rect key="frame" x="16" y="282" width="366" height="54.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Recovery Token (optional)" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Iti-sU-LPf">
<rect key="frame" x="0.0" y="0.0" width="366" height="20.5"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" borderStyle="roundedRect" placeholder="token" textAlignment="natural" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="f6p-S5-h3D">
<rect key="frame" x="0.0" y="20.5" width="366" height="34"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<textInputTraits key="textInputTraits" returnKeyType="done"/>
<connections>
<outlet property="delegate" destination="BYZ-38-t0r" id="ZZA-U1-ym4"/>
</connections>
</textField>
</subviews>
</stackView>
</subviews>
<edgeInsets key="layoutMargins" top="16" left="16" bottom="16" right="16"/>
</stackView>
Expand Down Expand Up @@ -141,6 +160,7 @@
<connections>
<outlet property="clientIdField" destination="BxJ-uw-0ZR" id="ily-M9-7g2"/>
<outlet property="issuerField" destination="kXk-Cn-yoW" id="Ito-I0-Q7K"/>
<outlet property="recoveryTokenField" destination="f6p-S5-h3D" id="W2T-ba-JdX"/>
<outlet property="redirectField" destination="ZSr-Pi-X8m" id="2lB-bD-blw"/>
<outlet property="scopesField" destination="8JU-4E-haT" id="6II-Re-T0x"/>
</connections>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -58,6 +60,7 @@ struct ClientConfiguration {
self.clientId = clientId
self.scopes = scopes
self.redirectUri = redirectUri
self.recoveryToken = recoveryToken
self.shouldSave = shouldSave
}

Expand All @@ -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) {
Expand All @@ -93,6 +98,9 @@ struct ClientConfiguration {
case "--scopes", "-s":
scopes = argument

case "--recoveryToken", "-t":
recoveryToken = argument

default: break
}
key = nil
Expand All @@ -105,6 +113,7 @@ struct ClientConfiguration {
issuer: issuer!,
redirectUri: redirectUri!,
scopes: scopes,
recoveryToken: recoveryToken,
shouldSave: false)
}

Expand All @@ -127,6 +136,7 @@ struct ClientConfiguration {
issuer: issuer,
redirectUri: redirectUri,
scopes: scopes,
recoveryToken: nil,
shouldSave: false)
}

Expand All @@ -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)
}

Expand All @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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)))
}

Expand All @@ -70,6 +73,7 @@ class ClientConfigurationViewController: UIViewController {
issuer: issuerUrl,
redirectUri: redirectUri,
scopes: scopes,
recoveryToken: recoveryTokenField.text,
shouldSave: true)
configuration?.save()
dismiss(animated: true)
Expand Down Expand Up @@ -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
Expand Down
39 changes: 26 additions & 13 deletions Sources/OktaIdx/IDXClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a breaking API change? (I think it is, which is why I'm asking)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is technically a breaking change, though the 2.0.0 release was just cut recently which introduced even more significant breaking changes. Since this argument is optional, and very few people will actually use it, I'm okay with just doing a minor version change for this update.

completion: @escaping (Result<IDXClient, IDXClientError>) -> 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)
Expand All @@ -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<IDXClient, IDXClientError>) -> Void)
{
api.start(state: state) { result in
api.start(options: options) { result in
switch result {
case .failure(let error):
completion(.failure(error))
Expand Down Expand Up @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<IDXClient.Context, IDXClientError>) -> Void)
func start(options: [IDXClient.Option:String]?, completion: @escaping (Result<IDXClient.Context, IDXClientError>) -> Void)
func resume(completion: @escaping (Result<Response, IDXClientError>) -> Void)
func proceed(remediation option: Remediation,
completion: @escaping (Result<Response, IDXClientError>) -> Void)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,22 +34,31 @@ extension IDXClient {
}

extension IDXClient.APIVersion1: IDXClientAPIImpl {
func start(state: String?, completion: @escaping (Result<IDXClient.Context, IDXClientError>) -> Void) {
func start(options: [IDXClient.Option : String]?, completion: @escaping (Result<IDXClient.Context, IDXClientError>) -> 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)))
}
Expand Down
Loading