Skip to content

Commit

Permalink
Merge pull request #380 from stytchauth/password-discovery
Browse files Browse the repository at this point in the history
Add password discovery
  • Loading branch information
nidal-stytch authored Jan 23, 2025
2 parents 839cc64 + 16d7624 commit 4760365
Show file tree
Hide file tree
Showing 49 changed files with 950 additions and 219 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ on:
pull_request:

env:
DEVELOPER_DIR: /Applications/Xcode_14.3.app/Contents/Developer
DEVELOPER_DIR: /Applications/Xcode_15.0.app/Contents/Developer

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.ref != 'refs/heads/main' || github.run_number }}
Expand Down
18 changes: 13 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -55,20 +55,28 @@ test-all: codegen

.PHONY: test tests test-macos
test tests test-macos: codegen
$(TEST) macosx$(MACOS_VERSION) -destination "OS=$(MACOS_VERSION),platform=macOS" -enableCodeCoverage YES -derivedDataPath .build | $(XCPRETTY)
@xcodebuild -showsdks
@xcrun simctl list devices
$(TEST) macosx14.0 -destination "OS=14.0,platform=macOS" -enableCodeCoverage YES -derivedDataPath .build | $(XCPRETTY)

.PHONY: test-ios
test-ios: codegen
$(TEST) iphonesimulator$(IOS_VERSION) -destination "OS=$(IOS_VERSION),name=iPhone 14 Pro" | $(XCPRETTY)
$(UI_UNIT_TESTS) iphonesimulator$(IOS_VERSION) -destination "OS=$(IOS_VERSION),name=iPhone 14 Pro" | $(XCPRETTY)
@xcodebuild -showsdks
@xcrun simctl list devices
$(TEST) iphonesimulator17.0 -destination "OS=17.2,name=iPhone 15 Pro" | $(XCPRETTY)
$(UI_UNIT_TESTS) iphonesimulator17.0 -destination "OS=17.2,name=iPhone 15 Pro" | $(XCPRETTY)

.PHONY: test-tvos
test-tvos: codegen
$(TEST) appletvsimulator$(IOS_VERSION) -destination "OS=$(IOS_VERSION),name=Apple TV" | $(XCPRETTY)
@xcodebuild -showsdks
@xcrun simctl list devices
$(TEST) appletvsimulator17.0 -destination "OS=17.0,name=Apple TV" | $(XCPRETTY)

.PHONY: test-watchos
test-watchos: codegen
$(TEST) watchsimulator$(WATCHOS_VERSION) -destination "OS=$(WATCHOS_VERSION),name=Apple Watch Ultra (49mm)" | $(XCPRETTY)
@xcodebuild -showsdks
@xcrun simctl list devices
$(TEST) watchsimulator10.0 -destination "OS=10.0,name=Apple Watch Ultra (49mm)" | $(XCPRETTY)

.PHONY: tools
tools:
Expand Down
4 changes: 2 additions & 2 deletions Sources/StytchCore/DeeplinkHandledStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
Represents whether a deeplink was able to be handled
Session-related information when appropriate.
*/
public enum DeeplinkHandledStatus<AuthenticateResponse: Sendable, DeeplinkTokenType: Sendable>: Sendable {
public enum DeeplinkHandledStatus<AuthenticateResponse: Sendable, DeeplinkTokenType: Sendable, DeeplinkRedirectType: Sendable>: Sendable {
/// The handler was successfully able to handle the given item.
case handled(response: AuthenticateResponse)
/// The handler was unable to handle the given item.
case notHandled
/// The handler recognized the token type, but manual handing is required. This should only be encountered for password reset deeplinks.
case manualHandlingRequired(DeeplinkTokenType, token: String)
case manualHandlingRequired(DeeplinkTokenType, DeeplinkRedirectType, token: String)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Generated using Sourcery 2.0.2 — https://github.com/krzysztofzablocki/Sourcery
// DO NOT EDIT
import Combine
import Foundation

public extension StytchB2BClient.Passwords.Discovery {
/// Authenticate an email/password combination in the discovery flow.
/// This authenticate flow is only valid for cross-org passwords use cases, and is not tied to a specific organization.
func authenticate(parameters: AuthenticateParameters, completion: @escaping Completion<StytchB2BClient.DiscoveryAuthenticateResponse>) {
Task {
do {
completion(.success(try await authenticate(parameters: parameters)))
} catch {
completion(.failure(error))
}
}
}

/// Authenticate an email/password combination in the discovery flow.
/// This authenticate flow is only valid for cross-org passwords use cases, and is not tied to a specific organization.
func authenticate(parameters: AuthenticateParameters) -> AnyPublisher<StytchB2BClient.DiscoveryAuthenticateResponse, Error> {
return Deferred {
Future({ promise in
Task {
do {
promise(.success(try await authenticate(parameters: parameters)))
} catch {
promise(.failure(error))
}
}
})
}
.eraseToAnyPublisher()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Generated using Sourcery 2.0.2 — https://github.com/krzysztofzablocki/Sourcery
// DO NOT EDIT
import Combine
import Foundation

public extension StytchB2BClient.Passwords.Discovery {
/// Reset the password associated with an email and start an intermediate session.
/// This endpoint checks that the password reset token is valid, hasn’t expired, or already been used.
func resetByEmail(parameters: ResetByEmailParameters, completion: @escaping Completion<StytchB2BClient.DiscoveryAuthenticateResponse>) {
Task {
do {
completion(.success(try await resetByEmail(parameters: parameters)))
} catch {
completion(.failure(error))
}
}
}

/// Reset the password associated with an email and start an intermediate session.
/// This endpoint checks that the password reset token is valid, hasn’t expired, or already been used.
func resetByEmail(parameters: ResetByEmailParameters) -> AnyPublisher<StytchB2BClient.DiscoveryAuthenticateResponse, Error> {
return Deferred {
Future({ promise in
Task {
do {
promise(.success(try await resetByEmail(parameters: parameters)))
} catch {
promise(.failure(error))
}
}
})
}
.eraseToAnyPublisher()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Generated using Sourcery 2.0.2 — https://github.com/krzysztofzablocki/Sourcery
// DO NOT EDIT
import Combine
import Foundation

public extension StytchB2BClient.Passwords.Discovery {
/// Initiates a password reset for the email address provided, when cross-org passwords are enabled.
/// This will trigger an email to be sent to the address, containing a magic link that will allow them to set a new password and authenticate.
func resetByEmailStart(parameters: ResetByEmailStartParameters, completion: @escaping Completion<BasicResponse>) {
Task {
do {
completion(.success(try await resetByEmailStart(parameters: parameters)))
} catch {
completion(.failure(error))
}
}
}

/// Initiates a password reset for the email address provided, when cross-org passwords are enabled.
/// This will trigger an email to be sent to the address, containing a magic link that will allow them to set a new password and authenticate.
func resetByEmailStart(parameters: ResetByEmailStartParameters) -> AnyPublisher<BasicResponse, Error> {
return Deferred {
Future({ promise in
Task {
do {
promise(.success(try await resetByEmailStart(parameters: parameters)))
} catch {
promise(.failure(error))
}
}
})
}
.eraseToAnyPublisher()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public extension StytchB2BClient {
/// - Parameters:
/// - url: A `URL` passed to your application as a deeplink.
/// - sessionDuration: The duration, in minutes, of the requested session. Defaults to 5 minutes.
static func handle(url: URL, sessionDuration: Minutes, completion: @escaping Completion<DeeplinkHandledStatus<DeeplinkResponse, DeeplinkTokenType>>) {
static func handle(url: URL, sessionDuration: Minutes, completion: @escaping Completion<DeeplinkHandledStatus<DeeplinkResponse, DeeplinkTokenType, DeeplinkRedirectType>>) {
Task {
do {
completion(.success(try await handle(url: url, sessionDuration: sessionDuration)))
Expand All @@ -28,7 +28,7 @@ public extension StytchB2BClient {
/// - Parameters:
/// - url: A `URL` passed to your application as a deeplink.
/// - sessionDuration: The duration, in minutes, of the requested session. Defaults to 5 minutes.
static func handle(url: URL, sessionDuration: Minutes) -> AnyPublisher<DeeplinkHandledStatus<DeeplinkResponse, DeeplinkTokenType>, Error> {
static func handle(url: URL, sessionDuration: Minutes) -> AnyPublisher<DeeplinkHandledStatus<DeeplinkResponse, DeeplinkTokenType, DeeplinkRedirectType>, Error> {
return Deferred {
Future({ promise in
Task {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public extension StytchClient {
/// - Parameters:
/// - url: A `URL` passed to your application as a deeplink.
/// - sessionDuration: The duration, in minutes, of the requested session. Defaults to 5 minutes.
static func handle(url: URL, sessionDuration: Minutes = .defaultSessionDuration, completion: @escaping Completion<DeeplinkHandledStatus<DeeplinkResponse, DeeplinkTokenType>>) {
static func handle(url: URL, sessionDuration: Minutes = .defaultSessionDuration, completion: @escaping Completion<DeeplinkHandledStatus<DeeplinkResponse, DeeplinkTokenType, DeeplinkRedirectType>>) {
Task {
do {
completion(.success(try await handle(url: url, sessionDuration: sessionDuration)))
Expand All @@ -28,7 +28,7 @@ public extension StytchClient {
/// - Parameters:
/// - url: A `URL` passed to your application as a deeplink.
/// - sessionDuration: The duration, in minutes, of the requested session. Defaults to 5 minutes.
static func handle(url: URL, sessionDuration: Minutes = .defaultSessionDuration) -> AnyPublisher<DeeplinkHandledStatus<DeeplinkResponse, DeeplinkTokenType>, Error> {
static func handle(url: URL, sessionDuration: Minutes = .defaultSessionDuration) -> AnyPublisher<DeeplinkHandledStatus<DeeplinkResponse, DeeplinkTokenType, DeeplinkRedirectType>, Error> {
return Deferred {
Future({ promise in
Task {
Expand Down
1 change: 0 additions & 1 deletion Sources/StytchCore/Networking/NetworkingClient+Live.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ extension NetworkingClient {
} else {
return try await networkRequestHandler.handleDFPDisabled(session: session, request: request, captcha: captcha, requestHandler: defaultRequestHandler)
}

#endif
return try await defaultRequestHandler(session: session, request: request)
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/StytchCore/SharedModels/BootstrapResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ extension BootstrapResponseData {
}
}

public struct PasswordConfig: Codable {
public struct PasswordConfig: Codable, Sendable {
public let ludsComplexity: Int
public let ludsMinimumCount: Int
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import Foundation

public extension StytchB2BClient.Passwords {
/// The interface for interacting with otp email discovery products.
var discovery: Discovery {
.init(router: router.scopedRouter {
$0.discovery
})
}
}

public extension StytchB2BClient.Passwords {
struct Discovery {
let router: NetworkingRouter<StytchB2BClient.PasswordsRoute.DiscoveryRoute>

@Dependency(\.pkcePairManager) private var pkcePairManager
@Dependency(\.sessionManager) private var sessionManager

// sourcery: AsyncVariants, (NOTE: - must use /// doc comment styling)
/// Initiates a password reset for the email address provided, when cross-org passwords are enabled.
/// This will trigger an email to be sent to the address, containing a magic link that will allow them to set a new password and authenticate.
public func resetByEmailStart(parameters: ResetByEmailStartParameters) async throws -> BasicResponse {
let pkcePair = try pkcePairManager.generateAndReturnPKCECodePair()
return try await router.post(
to: .resetByEmailStart,
parameters: CodeChallengedParameters(
codingPrefix: .pkce,
codeChallenge: pkcePair.codeChallenge,
codeChallengeMethod: pkcePair.method,
wrapped: parameters
),
useDFPPA: true
)
}

// sourcery: AsyncVariants, (NOTE: - must use /// doc comment styling)
/// Reset the password associated with an email and start an intermediate session.
/// This endpoint checks that the password reset token is valid, hasn’t expired, or already been used.
public func resetByEmail(parameters: ResetByEmailParameters) async throws -> StytchB2BClient.DiscoveryAuthenticateResponse {
defer {
try? pkcePairManager.clearPKCECodePair()
}

guard let pkcePair: PKCECodePair = pkcePairManager.getPKCECodePair() else {
throw StytchSDKError.missingPKCE
}

let intermediateSessionTokenParameters = IntermediateSessionTokenParameters(
intermediateSessionToken: sessionManager.intermediateSessionToken,
wrapped: CodeVerifierParameters(
codingPrefix: .pkce,
codeVerifier: pkcePair.codeVerifier,
wrapped: parameters
)
)

return try await router.post(
to: .resetByEmail,
parameters: intermediateSessionTokenParameters,
useDFPPA: true
)
}

// sourcery: AsyncVariants, (NOTE: - must use /// doc comment styling)
/// Authenticate an email/password combination in the discovery flow.
/// This authenticate flow is only valid for cross-org passwords use cases, and is not tied to a specific organization.
public func authenticate(parameters: AuthenticateParameters) async throws -> StytchB2BClient.DiscoveryAuthenticateResponse {
try await router.post(
to: .authenticate,
parameters: parameters,
useDFPPA: true
)
}
}
}

public extension StytchB2BClient.Passwords.Discovery {
struct ResetByEmailStartParameters: Encodable, Sendable {
let emailAddress: String
let discoveryRedirectUrl: URL?
let resetPasswordRedirectUrl: URL?
let resetPasswordExpirationMinutes: Minutes?
let resetPasswordTemplateId: String?

/// - Parameters:
/// - emailAddress: The email that requested the password reset.
/// - discoveryRedirectUrl: The URL that the Member clicks from the password reset email to skip resetting their
/// password and directly log in. This should be a URL that your app receives, parses, and subsequently sends an API
/// request to the magic link authenticate endpoint to complete the login process without resetting their password.
/// If this value is not passed, the login redirect URL that you set in your Dashboard is used. If you have not set
/// a default login redirect URL, an error is returned.
/// - resetPasswordRedirectUrl: The URL that the Member clicks from the password reset email to finish the reset password
/// flow. This should be a URL that your app receives and parses before showing your app's reset password page. After
/// the Member submits a new password to your app, it should send an API request to complete the password reset process.
/// If this value is not passed, the default reset password redirect URL that you set in your Dashboard is used. If you
/// have not set a default reset password redirect URL, an error is returned.
/// - resetPasswordExpirationMinutes: Set the expiration for the password reset, in minutes. By default, it expires in
/// 30 minutes. The minimum expiration is 5 minutes, and the maximum is 7 days (10080 minutes).
/// - resetPasswordTemplateId: The email template ID to use for password reset. If not provided, your default email
/// template will be sent. If providing a template ID, it must be either a template using Stytch's customizations or a
/// Passwords reset custom HTML template.
public init(
emailAddress: String,
discoveryRedirectUrl: URL? = nil,
resetPasswordRedirectUrl: URL? = nil,
resetPasswordExpirationMinutes: Minutes = .defaultSessionDuration,
resetPasswordTemplateId: String? = nil
) {
self.emailAddress = emailAddress
self.discoveryRedirectUrl = discoveryRedirectUrl
self.resetPasswordRedirectUrl = resetPasswordRedirectUrl
self.resetPasswordExpirationMinutes = resetPasswordExpirationMinutes
self.resetPasswordTemplateId = resetPasswordTemplateId
}
}

struct ResetByEmailParameters: Encodable, Sendable {
let passwordResetToken: String
let password: String

/// - Parameters:
/// - passwordResetToken: The token to authenticate.
/// - password: The new password for the Member.
public init(passwordResetToken: String, password: String) {
self.passwordResetToken = passwordResetToken
self.password = password
}
}

struct AuthenticateParameters: Encodable, Sendable {
let emailAddress: String
let password: String

/// - Parameters:
/// - emailAddress: The email attempting to login.
/// - password: The password for the email address.
public init(emailAddress: String, password: String) {
self.emailAddress = emailAddress
self.password = password
}
}
}

public extension StytchB2BClient.Passwords.Discovery {}
20 changes: 20 additions & 0 deletions Sources/StytchCore/StytchB2BClient/StytchB2BClient+Routes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ extension StytchB2BClient {
case resetBySession
case authenticate
case strengthCheck
case discovery(DiscoveryRoute)

var path: Path {
switch self {
Expand All @@ -254,6 +255,25 @@ extension StytchB2BClient {
return "authenticate"
case .strengthCheck:
return "strength_check"
case let .discovery(route):
return "discovery".appendingPath(route.path)
}
}

enum DiscoveryRoute: RouteType {
case resetByEmailStart
case resetByEmail
case authenticate

var path: Path {
switch self {
case .resetByEmailStart:
return "reset/start"
case .resetByEmail:
return "reset"
case .authenticate:
return "authenticate"
}
}
}
}
Expand Down
Loading

0 comments on commit 4760365

Please sign in to comment.