diff --git a/Sources/StytchCore/Generated/Sessions.exchnage+AsyncVariants.generated.swift b/Sources/StytchCore/Generated/Sessions.exchnage+AsyncVariants.generated.swift new file mode 100644 index 0000000000..7b4c190a21 --- /dev/null +++ b/Sources/StytchCore/Generated/Sessions.exchnage+AsyncVariants.generated.swift @@ -0,0 +1,33 @@ +// Generated using Sourcery 2.0.2 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT +import Combine +import Foundation + +public extension Sessions { + /// Use this endpoint to exchange a Member's existing session for another session in a different Organization. + func exchnage(parameters: ExchangeParameters, completion: @escaping Completion) { + Task { + do { + completion(.success(try await exchnage(parameters: parameters))) + } catch { + completion(.failure(error)) + } + } + } + + /// Use this endpoint to exchange a Member's existing session for another session in a different Organization. + func exchnage(parameters: ExchangeParameters) -> AnyPublisher { + return Deferred { + Future({ promise in + Task { + do { + promise(.success(try await exchnage(parameters: parameters))) + } catch { + promise(.failure(error)) + } + } + }) + } + .eraseToAnyPublisher() + } +} diff --git a/Sources/StytchCore/StytchB2BClient/StytchB2BClient+Sessions.swift b/Sources/StytchCore/StytchB2BClient/StytchB2BClient+Sessions.swift index 328bcb9178..eb4bf6cb8c 100644 --- a/Sources/StytchCore/StytchB2BClient/StytchB2BClient+Sessions.swift +++ b/Sources/StytchCore/StytchB2BClient/StytchB2BClient+Sessions.swift @@ -2,3 +2,29 @@ public extension StytchB2BClient { /// The interface for interacting with sessions products. static var sessions: Sessions { .init(router: router.scopedRouter { $0.sessions }) } } + +public extension Sessions { + // sourcery: AsyncVariants, (NOTE: - must use /// doc comment styling) + /// Use this endpoint to exchange a Member's existing session for another session in a different Organization. + func exchnage(parameters: ExchangeParameters) async throws -> B2BAuthenticateResponse { + try await router.post(to: .exchange, parameters: parameters) + } +} + +public extension Sessions { + /// The dedicated parameters type for session `exchange` calls. + struct ExchangeParameters: Codable { + /// The ID of the organization that the new session should belong to. + public let organizationID: String + /// The duration, in minutes, for the requested session. Defaults to 30 minutes. + public let sessionDurationMinutes: Minutes + /// The locale will be used if an OTP code is sent to the member's phone number as part of a secondary authentication requirement. + public let locale: String? + + public init(organizationID: String, sessionDurationMinutes: Minutes = .defaultSessionDuration, locale: String? = nil) { + self.organizationID = organizationID + self.sessionDurationMinutes = sessionDurationMinutes + self.locale = locale + } + } +} diff --git a/Sources/StytchCore/StytchClientCommon/Sessions/SessionsRoute.swift b/Sources/StytchCore/StytchClientCommon/Sessions/SessionsRoute.swift index fbd14a9b22..6915b1847c 100644 --- a/Sources/StytchCore/StytchClientCommon/Sessions/SessionsRoute.swift +++ b/Sources/StytchCore/StytchClientCommon/Sessions/SessionsRoute.swift @@ -1,6 +1,7 @@ enum SessionsRoute: String, RouteType { case authenticate case revoke + case exchange var path: Path { .init(rawValue: rawValue) diff --git a/StytchDemo/B2BWorkbench/AuthMethodControllers/DiscoveryViewController.swift b/StytchDemo/B2BWorkbench/AuthMethodControllers/DiscoveryViewController.swift index 6ade33c4db..d7164352f7 100644 --- a/StytchDemo/B2BWorkbench/AuthMethodControllers/DiscoveryViewController.swift +++ b/StytchDemo/B2BWorkbench/AuthMethodControllers/DiscoveryViewController.swift @@ -64,8 +64,6 @@ final class DiscoveryViewController: UIViewController { }) }() - private let defaults: UserDefaults = .standard - override func viewDidLoad() { super.viewDidLoad() diff --git a/StytchDemo/B2BWorkbench/AuthMethodControllers/MagicLinksViewController.swift b/StytchDemo/B2BWorkbench/AuthMethodControllers/MagicLinksViewController.swift index 4ca58420b6..84fc379be8 100644 --- a/StytchDemo/B2BWorkbench/AuthMethodControllers/MagicLinksViewController.swift +++ b/StytchDemo/B2BWorkbench/AuthMethodControllers/MagicLinksViewController.swift @@ -66,8 +66,6 @@ final class MagicLinksViewController: UIViewController { }) }() - private let defaults: UserDefaults = .standard - override func viewDidLoad() { super.viewDidLoad() @@ -90,9 +88,9 @@ final class MagicLinksViewController: UIViewController { stackView.addArrangedSubview(discoverySendButton) stackView.addArrangedSubview(inviteSendButton) - emailTextField.text = defaults.string(forKey: Constants.emailDefaultsKey) - orgIdTextField.text = defaults.string(forKey: Constants.orgIdDefaultsKey) - redirectUrlTextField.text = defaults.string(forKey: Constants.redirectUrlDefaultsKey) ?? "b2bworkbench://auth" + emailTextField.text = UserDefaults.standard.string(forKey: Constants.emailDefaultsKey) + orgIdTextField.text = UserDefaults.standard.string(forKey: Constants.orgIdDefaultsKey) + redirectUrlTextField.text = UserDefaults.standard.string(forKey: Constants.redirectUrlDefaultsKey) ?? "b2bworkbench://auth" } func submit() { @@ -100,9 +98,9 @@ final class MagicLinksViewController: UIViewController { guard let orgId = orgIdTextField.text, !orgId.isEmpty else { return } guard let redirectUrl = redirectUrlTextField.text.map(URL.init(string:)) else { return } - defaults.set(email, forKey: Constants.emailDefaultsKey) - defaults.set(orgId, forKey: Constants.orgIdDefaultsKey) - defaults.set(redirectUrl?.absoluteURL, forKey: Constants.redirectUrlDefaultsKey) + UserDefaults.standard.set(email, forKey: Constants.emailDefaultsKey) + UserDefaults.standard.set(orgId, forKey: Constants.orgIdDefaultsKey) + UserDefaults.standard.set(redirectUrl?.absoluteURL, forKey: Constants.redirectUrlDefaultsKey) Task { do { @@ -126,9 +124,9 @@ final class MagicLinksViewController: UIViewController { guard let orgId = orgIdTextField.text, !orgId.isEmpty else { return } guard let redirectUrl = redirectUrlTextField.text.map(URL.init(string:)) else { return } - defaults.set(email, forKey: Constants.emailDefaultsKey) - defaults.set(orgId, forKey: Constants.orgIdDefaultsKey) - defaults.set(redirectUrl?.absoluteURL, forKey: Constants.redirectUrlDefaultsKey) + UserDefaults.standard.set(email, forKey: Constants.emailDefaultsKey) + UserDefaults.standard.set(orgId, forKey: Constants.orgIdDefaultsKey) + UserDefaults.standard.set(redirectUrl?.absoluteURL, forKey: Constants.redirectUrlDefaultsKey) Task { do { @@ -150,9 +148,9 @@ final class MagicLinksViewController: UIViewController { guard let orgId = orgIdTextField.text, !orgId.isEmpty else { return } guard let redirectUrl = redirectUrlTextField.text.map(URL.init(string:)) else { return } - defaults.set(email, forKey: Constants.emailDefaultsKey) - defaults.set(orgId, forKey: Constants.orgIdDefaultsKey) - defaults.set(redirectUrl?.absoluteURL, forKey: Constants.redirectUrlDefaultsKey) + UserDefaults.standard.set(email, forKey: Constants.emailDefaultsKey) + UserDefaults.standard.set(orgId, forKey: Constants.orgIdDefaultsKey) + UserDefaults.standard.set(redirectUrl?.absoluteURL, forKey: Constants.redirectUrlDefaultsKey) Task { do { diff --git a/StytchDemo/B2BWorkbench/AuthMethodControllers/PasswordsViewController.swift b/StytchDemo/B2BWorkbench/AuthMethodControllers/PasswordsViewController.swift index c85ece4459..150b030529 100644 --- a/StytchDemo/B2BWorkbench/AuthMethodControllers/PasswordsViewController.swift +++ b/StytchDemo/B2BWorkbench/AuthMethodControllers/PasswordsViewController.swift @@ -93,8 +93,6 @@ final class PasswordsViewController: UIViewController { }) }() - private let defaults: UserDefaults = .standard - private let passwordClient = StytchB2BClient.passwords override func viewDidLoad() { @@ -127,9 +125,9 @@ final class PasswordsViewController: UIViewController { stackView.addArrangedSubview(resetByEmailStartButton) stackView.addArrangedSubview(resetBySessionButton) - emailTextField.text = defaults.string(forKey: Constants.emailDefaultsKey) - orgIdTextField.text = defaults.string(forKey: Constants.orgIdDefaultsKey) - redirectUrlTextField.text = defaults.string(forKey: Constants.redirectUrlDefaultsKey) ?? "b2bworkbench://auth" + emailTextField.text = UserDefaults.standard.string(forKey: Constants.emailDefaultsKey) + orgIdTextField.text = UserDefaults.standard.string(forKey: Constants.orgIdDefaultsKey) + redirectUrlTextField.text = UserDefaults.standard.string(forKey: Constants.redirectUrlDefaultsKey) ?? "b2bworkbench://auth" if StytchB2BClient.sessions.memberSession == nil { resetBySessionButton.isHidden = true @@ -159,7 +157,7 @@ final class PasswordsViewController: UIViewController { password: values.password ) ) - print("authenticated!") + presentAlertWithTitle(alertTitle: "Authenticated!") } catch { print("authenticate error: \(error.errorInfo)") } @@ -170,7 +168,7 @@ final class PasswordsViewController: UIViewController { guard let password = passwordTextField.text, !password.isEmpty else { return } if let email = emailTextField.text, !email.isEmpty { - defaults.set(email, forKey: Constants.emailDefaultsKey) + UserDefaults.standard.set(email, forKey: Constants.emailDefaultsKey) } Task { @@ -247,17 +245,17 @@ final class PasswordsViewController: UIViewController { let redirectUrl = redirectUrlTextField.text.flatMap(URL.init(string:)) if let redirectUrl { - defaults.set(redirectUrl.absoluteString, forKey: Constants.redirectUrlDefaultsKey) + UserDefaults.standard.set(redirectUrl.absoluteString, forKey: Constants.redirectUrlDefaultsKey) } if let email = emailTextField.text, !email.isEmpty { - defaults.set(email, forKey: Constants.emailDefaultsKey) + UserDefaults.standard.set(email, forKey: Constants.emailDefaultsKey) } if let orgId = orgIdTextField.text, !orgId.isEmpty { - defaults.set(orgId, forKey: Constants.orgIdDefaultsKey) + UserDefaults.standard.set(orgId, forKey: Constants.orgIdDefaultsKey) } - defaults.set(orgId, forKey: Constants.orgIdDefaultsKey) + UserDefaults.standard.set(orgId, forKey: Constants.orgIdDefaultsKey) return (.init(rawValue: orgId), passwordTextField.text ?? "", emailTextField.text ?? "", redirectUrl) } diff --git a/StytchDemo/B2BWorkbench/AuthMethodControllers/SSOViewController.swift b/StytchDemo/B2BWorkbench/AuthMethodControllers/SSOViewController.swift index bee353cd64..1e634cdf58 100644 --- a/StytchDemo/B2BWorkbench/AuthMethodControllers/SSOViewController.swift +++ b/StytchDemo/B2BWorkbench/AuthMethodControllers/SSOViewController.swift @@ -40,8 +40,6 @@ final class SSOViewController: UIViewController { return .init(configuration: configuration, primaryAction: startAction) }() - private let defaults: UserDefaults = .standard - override func viewDidLoad() { super.viewDidLoad() @@ -61,14 +59,14 @@ final class SSOViewController: UIViewController { stackView.addArrangedSubview(redirectUrlTextField) stackView.addArrangedSubview(startButton) - redirectUrlTextField.text = defaults.string(forKey: Constants.redirectUrlDefaultsKey) ?? "b2bworkbench://auth" + redirectUrlTextField.text = UserDefaults.standard.string(forKey: Constants.redirectUrlDefaultsKey) ?? "b2bworkbench://auth" } private func start() { guard let connectionId = connectionIdTextField.text, !connectionId.isEmpty else { return } guard let redirectUrl = redirectUrlTextField.text.flatMap(URL.init(string:)) else { return } - defaults.set(redirectUrl.absoluteURL, forKey: Constants.redirectUrlDefaultsKey) + UserDefaults.standard.set(redirectUrl.absoluteURL, forKey: Constants.redirectUrlDefaultsKey) Task { do { diff --git a/StytchDemo/B2BWorkbench/AuthMethodControllers/SessionsViewController.swift b/StytchDemo/B2BWorkbench/AuthMethodControllers/SessionsViewController.swift index 064365d613..f26c06185e 100644 --- a/StytchDemo/B2BWorkbench/AuthMethodControllers/SessionsViewController.swift +++ b/StytchDemo/B2BWorkbench/AuthMethodControllers/SessionsViewController.swift @@ -7,6 +7,7 @@ final class SessionsViewController: UIViewController { view.layoutMargins = Constants.insets view.isLayoutMarginsRelativeArrangement = true view.axis = .vertical + view.distribution = .fillEqually view.spacing = 8 return view }() @@ -23,11 +24,26 @@ final class SessionsViewController: UIViewController { return .init(configuration: configuration, primaryAction: revokeAction) }() + private lazy var exchangeSessionButton: UIButton = { + var configuration: UIButton.Configuration = .borderedProminent() + configuration.title = "Exchange" + return .init(configuration: configuration, primaryAction: exchangeSessionsAction) + }() + + private lazy var orgIdTextField: UITextField = { + let textField: UITextField = .init(frame: .zero, primaryAction: exchangeSessionsAction) + textField.borderStyle = .roundedRect + textField.placeholder = "Organization ID To Exchange Session With" + textField.autocorrectionType = .no + textField.autocapitalizationType = .none + return textField + }() + private lazy var authenticateAction: UIAction = .init { _ in Task { do { - let resp = try await StytchB2BClient.sessions.authenticate(parameters: .init()) - print(resp) + let response = try await StytchB2BClient.sessions.authenticate(parameters: .init()) + print("authenticateAction response: \(response)") } catch { print("authenticateAction error: \(error.errorInfo)") } @@ -37,14 +53,36 @@ final class SessionsViewController: UIViewController { private lazy var revokeAction: UIAction = .init { _ in Task { do { - let resp = try await StytchB2BClient.sessions.revoke() - print(resp) + let response = try await StytchB2BClient.sessions.revoke() + print("revokeAction response: \(response)") } catch { print("revokeAction error: \(error.errorInfo)") } } } + private lazy var exchangeSessionsAction: UIAction = .init { _ in + self.exchangeSession() + } + + func exchangeSession() { + guard let organizationID = orgIdTextField.text else { + return + } + Task { + do { + let parameters = Sessions.ExchangeParameters(organizationID: organizationID) + let response = try await StytchB2BClient.sessions.exchnage(parameters: parameters) + UserDefaults.standard.set(organizationID, forKey: Constants.orgIdDefaultsKey) + orgIdTextField.text = "" + presentAlertWithTitle(alertTitle: "Session Exchanged to org with id: \(organizationID)") + print("exchangeAction response: \(response)") + } catch { + print("exchangeAction error: \(error.errorInfo)") + } + } + } + override func viewDidLoad() { super.viewDidLoad() @@ -62,5 +100,8 @@ final class SessionsViewController: UIViewController { stackView.addArrangedSubview(authenticateButton) stackView.addArrangedSubview(revokeButton) + stackView.addArrangedSubview(UIView()) + stackView.addArrangedSubview(exchangeSessionButton) + stackView.addArrangedSubview(orgIdTextField) } } diff --git a/StytchDemo/B2BWorkbench/RootViewController.swift b/StytchDemo/B2BWorkbench/RootViewController.swift index 620fc6445d..f13421bd5e 100644 --- a/StytchDemo/B2BWorkbench/RootViewController.swift +++ b/StytchDemo/B2BWorkbench/RootViewController.swift @@ -35,8 +35,6 @@ final class RootViewController: UIViewController { return view }() - private let defaults: UserDefaults = .standard - private var authChangeCancellable: AnyCancellable? override func viewDidLoad() { @@ -59,24 +57,21 @@ final class RootViewController: UIViewController { memberIdLabel.preferredMaxLayoutWidth = view.bounds.width - 2 * Constants.padding memberIdLabel.isHidden = true - publicTokenTextField.text = defaults.string(forKey: Constants.publicTokenDefaultsKey) + publicTokenTextField.text = UserDefaults.standard.string(forKey: Constants.publicTokenDefaultsKey) authChangeCancellable = StytchB2BClient.sessions.onAuthChange .map { _ in StytchB2BClient.member.getSync() } .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] member in - guard let self else { return } - - self.memberIdLabel.isHidden = member == nil - self.memberIdLabel.text = member.map { "Welcome, \($0.id.rawValue)!" } ?? "Logged out" - self.navigationController?.popToRootViewController(animated: true) + self?.memberIdLabel.isHidden = member == nil + self?.memberIdLabel.text = member.map { "Welcome, \($0.id.rawValue)!" } ?? "Logged out" }) } private func submit(token: String?) { guard let token = token, !token.isEmpty else { return } - defaults.set(token, forKey: Constants.publicTokenDefaultsKey) + UserDefaults.standard.set(token, forKey: Constants.publicTokenDefaultsKey) StytchB2BClient.configure(publicToken: token) navigationController?.pushViewController(AuthHomeViewController(), animated: true) diff --git a/StytchDemo/B2BWorkbench/SceneDelegate.swift b/StytchDemo/B2BWorkbench/SceneDelegate.swift index 31d1ae2275..c8255fc5d7 100644 --- a/StytchDemo/B2BWorkbench/SceneDelegate.swift +++ b/StytchDemo/B2BWorkbench/SceneDelegate.swift @@ -34,10 +34,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { print("discovery authResponse: \(authResponse)") } case let .manualHandlingRequired(_, token): - guard let controller = window?.rootViewController?.navigationController?.viewControllers.last as? PasswordsViewController else { - fatalError("Passwords controller should still be last") + let appViewController = window?.rootViewController as? AppViewController + if let passwordsViewController = appViewController?.viewControllers.last as? PasswordsViewController { + passwordsViewController.initiatePasswordReset(token: token) } - controller.initiatePasswordReset(token: token) case .notHandled: break } diff --git a/Tests/StytchCoreTests/B2BSessionsTestCase.swift b/Tests/StytchCoreTests/B2BSessionsTestCase.swift index 9fc09e0cc7..3b48b67750 100644 --- a/Tests/StytchCoreTests/B2BSessionsTestCase.swift +++ b/Tests/StytchCoreTests/B2BSessionsTestCase.swift @@ -59,4 +59,25 @@ final class B2BSessionsTestCase: BaseTestCase { XCTAssertEqual(StytchB2BClient.sessions.sessionToken, .opaque("token")) XCTAssertEqual(StytchB2BClient.sessions.sessionJwt, .jwt("jwt")) } + + func testSessionExchange() async throws { + networkInterceptor.responses { + B2BAuthenticateResponse.mock + } + + Current.timer = { _, _, _ in .init() } + + let organizationID = "org_123" + let parameters = Sessions.ExchangeParameters(organizationID: organizationID) + _ = try await StytchB2BClient.sessions.exchnage(parameters: parameters) + + try XCTAssertRequest( + networkInterceptor.requests[0], + urlString: "https://web.stytch.com/sdk/v1/b2b/sessions/exchange", + method: .post([ + "organization_id": JSON(stringLiteral: organizationID), + "session_duration_minutes": 30, + ]) + ) + } }