Skip to content

Commit

Permalink
SDK-1473: Add B2B member session exchange func
Browse files Browse the repository at this point in the history
  • Loading branch information
nidal-stytch committed May 23, 2024
1 parent 133396d commit 8d7527d
Show file tree
Hide file tree
Showing 11 changed files with 156 additions and 47 deletions.
Original file line number Diff line number Diff line change
@@ -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<B2BAuthenticateResponse>) {
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<B2BAuthenticateResponse, Error> {
return Deferred {
Future({ promise in
Task {
do {
promise(.success(try await exchnage(parameters: parameters)))
} catch {
promise(.failure(error))
}
}
})
}
.eraseToAnyPublisher()
}
}
26 changes: 26 additions & 0 deletions Sources/StytchCore/StytchB2BClient/StytchB2BClient+Sessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,29 @@ public extension StytchB2BClient {
/// The interface for interacting with sessions products.
static var sessions: Sessions<B2BAuthenticateResponse> { .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
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
enum SessionsRoute: String, RouteType {
case authenticate
case revoke
case exchange

var path: Path {
.init(rawValue: rawValue)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,6 @@ final class DiscoveryViewController: UIViewController {
})
}()

private let defaults: UserDefaults = .standard

override func viewDidLoad() {
super.viewDidLoad()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,6 @@ final class MagicLinksViewController: UIViewController {
})
}()

private let defaults: UserDefaults = .standard

override func viewDidLoad() {
super.viewDidLoad()

Expand All @@ -90,19 +88,19 @@ 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() {
guard let email = emailTextField.text, !email.isEmpty else { return }
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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,6 @@ final class PasswordsViewController: UIViewController {
})
}()

private let defaults: UserDefaults = .standard

private let passwordClient = StytchB2BClient.passwords

override func viewDidLoad() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -159,7 +157,7 @@ final class PasswordsViewController: UIViewController {
password: values.password
)
)
print("authenticated!")
presentAlertWithTitle(alertTitle: "Authenticated!")
} catch {
print("authenticate error: \(error.errorInfo)")
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,6 @@ final class SSOViewController: UIViewController {
return .init(configuration: configuration, primaryAction: startAction)
}()

private let defaults: UserDefaults = .standard

override func viewDidLoad() {
super.viewDidLoad()

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

Expand All @@ -62,5 +100,8 @@ final class SessionsViewController: UIViewController {

stackView.addArrangedSubview(authenticateButton)
stackView.addArrangedSubview(revokeButton)
stackView.addArrangedSubview(UIView())
stackView.addArrangedSubview(exchangeSessionButton)
stackView.addArrangedSubview(orgIdTextField)
}
}
13 changes: 4 additions & 9 deletions StytchDemo/B2BWorkbench/RootViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,6 @@ final class RootViewController: UIViewController {
return view
}()

private let defaults: UserDefaults = .standard

private var authChangeCancellable: AnyCancellable?

override func viewDidLoad() {
Expand All @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions StytchDemo/B2BWorkbench/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
21 changes: 21 additions & 0 deletions Tests/StytchCoreTests/B2BSessionsTestCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<B2BAuthenticateResponse>.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
])
)
}
}

0 comments on commit 8d7527d

Please sign in to comment.