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

[SDK-1473][B2B] Add exchange method to session client #233

Merged
merged 1 commit into from
May 23, 2024
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
@@ -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 exchange(parameters: ExchangeParameters, completion: @escaping Completion<B2BAuthenticateResponse>) {
Task {
do {
completion(.success(try await exchange(parameters: parameters)))
} catch {
completion(.failure(error))
}
}
}

/// Use this endpoint to exchange a Member's existing session for another session in a different Organization.
func exchange(parameters: ExchangeParameters) -> AnyPublisher<B2BAuthenticateResponse, Error> {
return Deferred {
Future({ promise in
Task {
do {
promise(.success(try await exchange(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 exchange(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)
Copy link
Contributor

Choose a reason for hiding this comment

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

Whats with the <B2BAuthenticateResponse>? Is it because it's using the Session client defined in StytchClientCommon? Should we go ahead and break out the session clients into separate ones for B2B and B2C? that way we could do StytchB2BClient.Sessions.ExchangeParameters(), right? Just for consistency with all the other products/methods. I think this current approach might lead to developer confusion.

Copy link
Contributor Author

@nidal-stytch nidal-stytch May 23, 2024

Choose a reason for hiding this comment

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

I personally feel that it is good the way it is and a good use of swift generics. If I can figure out something with sorcery where I can add a "where" clause to the protocol extensions then we can remove this.

This is the way around this:
public extension Sessions where AuthResponseType == B2BAuthenticateResponse

Then:
Sessions<B2BAuthenticateResponse>.ExchangeParameters
turns into:
Sessions.ExchangeParameters

But currently I can't figure out how to do that with sorcery and I posted a question about how to do that for the generated files here: krzysztofzablocki/Sourcery#1337.

I would only think we need to name space Session if it was to be used in tandem with the B2C session.

Copy link
Contributor

Choose a reason for hiding this comment

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

If we split out the sessions clients (which we do on Android), would that avoid the unknown sourcery stuff? If so, that plus being able to namespace the parameters seems like a win to me. We already have some customer confusion with using two different clients, I just think that this might add to that cognitive load.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed, after thinking about it and pair programming, I think we do need separate objects for b2b session and b2c session, lets make a ticket to capture this work.

let response = try await StytchB2BClient.sessions.exchange(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.exchange(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,
])
)
}
}
Loading