Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ struct OTPSignInView: View {
@State private var isSigningIn = false
@State private var showAlert = false
@State private var alertMessage = ""
@State private var showOTPVerification = false
@State private var verifiedAuthStatus: AuthenticationStatus?
@State private var pendingOTPRequest: OTPRequest?

var body: some View {
VStack(spacing: 20) {
Expand Down Expand Up @@ -50,16 +50,17 @@ struct OTPSignInView: View {
.alert(isPresented: $showAlert) {
Alert(title: Text("Alert"), message: Text(alertMessage), dismissButton: .default(Text("OK")))
}
.sheet(isPresented: $showOTPVerification) {
.sheet(item: $pendingOTPRequest) { request in
VerificationView(
authenticationStatus: $verifiedAuthStatus,
email: email
email: email,
requestId: request.requestId
)
.presentationDetents([.medium])
}
.onChange(of: verifiedAuthStatus) { _, value in
guard let value else { return }
showOTPVerification = false
pendingOTPRequest = nil
Task { @MainActor in
try? await Task.sleep(for: .seconds(AnimationConstants.duration))
withAnimation(AnimationConstants.easeInOut()) {
Expand All @@ -74,10 +75,10 @@ struct OTPSignInView: View {
isSigningIn = true
Task {
do {
try await authManager.sendEmailOtp(email: email)
let otpRequest = try await CrossmintSDK.shared.authClient.sendOTP(to: email)
isSigningIn = false
showOTPVerification = true
} catch let authError as AuthManagerError {
pendingOTPRequest = otpRequest
} catch let authError as AuthError {
isSigningIn = false
alertMessage = authError.errorMessage
showAlert = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,16 @@ struct VerificationView: View {
@State private var showAlert: Bool = false
@State private var alertMessage: String = ""
@State private var opacity: Double = 0
@State private var currentRequestId: String

let email: String

init(authenticationStatus: Binding<AuthenticationStatus?>, email: String, requestId: String) {
self._authenticationStatus = authenticationStatus
self.email = email
self._currentRequestId = State(initialValue: requestId)
}

var body: some View {
VStack(spacing: 20) {
HStack {
Expand Down Expand Up @@ -72,22 +79,11 @@ struct VerificationView: View {

private func verifyCode() {
guard !verificationCode.isEmpty else { return }

isVerifying = true
Task {
do {
let status = try await authManager.confirmEmailOtp(email: email, code: verificationCode)

isVerifying = false

if case let .authenticationStatus(authStatus) = status, case .authenticated = authStatus {
withAnimation(AnimationConstants.easeOut()) {
opacity = 0
}

try? await Task.sleep(for: .seconds(AnimationConstants.duration))
authenticationStatus = authStatus
}
let authStatus = try await resolveSession(for: verificationCode)
await animateSuccess(authStatus: authStatus)
} catch {
isVerifying = false
showAlert(with: "Error: \(error.localizedDescription)")
Expand All @@ -96,10 +92,24 @@ struct VerificationView: View {
}
}

private func resolveSession(for code: String) async throws -> AuthenticationStatus {
let session = try await CrossmintSDK.shared.authClient.verifyOTP(code: code, requestId: currentRequestId)
// secret is intentionally empty — refresh state lives in CrossmintAuthManager, not this binding
return .authenticated(email: session.user.email, jwt: session.jwt, secret: "")
}

private func animateSuccess(authStatus: AuthenticationStatus) async {
isVerifying = false
withAnimation(AnimationConstants.easeOut()) { opacity = 0 }
try? await Task.sleep(for: .seconds(AnimationConstants.duration))
authenticationStatus = authStatus
}

private func resendCode() {
Task {
do {
try await authManager.sendEmailOtp(email: email)
let otpRequest = try await CrossmintSDK.shared.authClient.sendOTP(to: email)
currentRequestId = otpRequest.requestId
showAlert(with: "A new verification code has been sent to your email.")
} catch {
showAlert(with: "Error sending new code: \(error.localizedDescription)")
Expand All @@ -114,7 +124,6 @@ struct VerificationView: View {
}

Task {
_ = await authManager.reset()
try? await Task.sleep(for: .seconds(AnimationConstants.duration))
authenticationStatus = .nonAuthenticated
}
Expand All @@ -129,6 +138,7 @@ struct VerificationView: View {
#Preview {
VerificationView(
authenticationStatus: .constant(nil),
email: "example@email.com"
email: "example@email.com",
requestId: "preview-request-id"
)
}
12 changes: 12 additions & 0 deletions Sources/CrossmintAuth/AuthClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//
// AuthClient.swift
// CrossmintSDK
//
// Created by Tomas Martins on 6/1/26.
//

public protocol AuthClient: Sendable {
func sendOTP(to email: String) async throws(AuthError) -> OTPRequest
func verifyOTP(code: String, requestId: String) async throws(AuthError) -> AuthSession
func logout() async
}
11 changes: 11 additions & 0 deletions Sources/CrossmintAuth/AuthSession.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//
// AuthSession.swift
// CrossmintSDK
//
// Created by Tomas Martins on 6/1/26.
//

public struct AuthSession: Sendable {
public let jwt: String
public let user: AuthUser
}
10 changes: 10 additions & 0 deletions Sources/CrossmintAuth/AuthUser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//
// AuthUser.swift
// CrossmintSDK
//
// Created by Tomas Martins on 6/1/26.
//

public struct AuthUser: Sendable {
public let email: String
}
46 changes: 46 additions & 0 deletions Sources/CrossmintAuth/DefaultAuthClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//
// DefaultAuthClient.swift
// CrossmintSDK
//
// Created by Tomas Martins on 6/1/26.
//

import Utils

public actor DefaultAuthClient: AuthClient {
private let authService: AuthService
private let authManager: CrossmintAuthManager
private var pendingEmailsByRequestId: [String: String] = [:]

package init(authService: AuthService, authManager: CrossmintAuthManager) {
self.authService = authService
self.authManager = authManager
}

public func sendOTP(to email: String) async throws(AuthError) -> OTPRequest {
let normalizedEmail = normalizeEmail(email)
guard isValidEmail(normalizedEmail) else {
throw AuthError.generic("Invalid email address")
}
let response = try await authService.validateEmail(ValidateEmailRequest(email: normalizedEmail))
pendingEmailsByRequestId[response.emailId] = normalizedEmail
return OTPRequest(requestId: response.emailId)
}

public func verifyOTP(code: String, requestId: String) async throws(AuthError) -> AuthSession {
guard let email = pendingEmailsByRequestId[requestId] else {
throw AuthError.generic("No pending OTP for the provided requestId")
}
let tokenResponse = try await authService.validateToken(
ValidateTokenRequest(email: email, token: code, emailID: requestId)
)
let session = try await authManager.establishSession(oneTimeSecret: tokenResponse.oneTimeSecret)
pendingEmailsByRequestId.removeValue(forKey: requestId)
return AuthSession(jwt: session.jwt, user: AuthUser(email: session.email))
}

public func logout() async {
_ = try? await authManager.logout()
pendingEmailsByRequestId.removeAll()
}
}
8 changes: 6 additions & 2 deletions Sources/CrossmintAuth/DefaultAuthManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,12 @@ public actor CrossmintAuthManager: AuthManager {
_authenticationStatus = authStatus
}

private func normalizeEmail(_ email: String) -> String {
email.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
internal func establishSession(oneTimeSecret: String) async throws(AuthError) -> (jwt: String, email: String) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

test?

let authStatus = try await refreshJWT(oneTimeSecret)
guard case let .authenticated(email, jwt, _) = authStatus else {
throw AuthError.generic("Session could not be established")
}
return (jwt: jwt, email: email)
}

private func startEmailValidation(email: String) async throws(AuthError) -> OTPAuthenticationStatus {
Expand Down
11 changes: 11 additions & 0 deletions Sources/CrossmintAuth/OTPRequest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//
// OTPRequest.swift
// CrossmintSDK
//
// Created by Tomas Martins on 6/1/26.
//

public struct OTPRequest: Sendable, Identifiable {
public var id: String { requestId }
public let requestId: String
}
1 change: 1 addition & 0 deletions Sources/CrossmintClient/ClientSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ import Wallet
protocol ClientSDK {
func crossmintWallets() -> CrossmintWallets
var authManager: CrossmintAuthManager { get }
var authClient: AuthClient { get }
var crossmintService: CrossmintService { get }
}
18 changes: 6 additions & 12 deletions Sources/CrossmintClient/CrossmintClientSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ final class CrossmintClientSDK: ClientSDK, Sendable {
private let secureWalletStorage: SecureWalletStorage
let crossmintService: CrossmintService
let authManager: CrossmintAuthManager
let authClient: any AuthClient

init(apiKey: ApiKey, authManager: CrossmintAuthManager? = nil) {
self.apiKey = apiKey
Expand All @@ -24,18 +25,11 @@ final class CrossmintClientSDK: ClientSDK, Sendable {
secureWalletStorage = KeychainSecureWalletStorage(bundleId: bundleId)
crossmintService = DefaultCrossmintService(apiKey: apiKey, appIdentifier: bundleId)

if let authManager {
self.authManager = authManager
} else {
self.authManager = CrossmintAuthManager(
authService: DefaultAuthService(crossmintService: crossmintService),
secureStorage: secureStorage
)
}
}

var isProductionEnvironment: Bool {
crossmintService.isProductionEnvironment
let authService = DefaultAuthService(crossmintService: crossmintService)
let resolvedAuthManager = authManager
?? CrossmintAuthManager(authService: authService, secureStorage: secureStorage)
self.authManager = resolvedAuthManager
self.authClient = DefaultAuthClient(authService: authService, authManager: resolvedAuthManager)
}

func crossmintWallets() -> CrossmintWallets {
Expand Down
2 changes: 2 additions & 0 deletions Sources/CrossmintClient/SwiftUI/CrossmintSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ final public class CrossmintSDK: ObservableObject {
public let crossmintWallets: CrossmintWallets
public let authManager: CrossmintAuthManager
public let crossmintService: CrossmintService
public let authClient: AuthClient

let crossmintTEE: CrossmintTEE

Expand Down Expand Up @@ -84,6 +85,7 @@ final public class CrossmintSDK: ObservableObject {
sdk = innerSdk
crossmintWallets = innerSdk.crossmintWallets()
self.authManager = authManager
self.authClient = innerSdk.authClient
crossmintService = innerSdk.crossmintService
crossmintTEE = CrossmintTEE.start(
auth: authManager,
Expand Down
4 changes: 4 additions & 0 deletions Sources/Utils/EmailValidation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ import SwiftEmailValidator
public func isValidEmail(_ email: String) -> Bool {
return EmailSyntaxValidator.correctlyFormatted(email)
}

public func normalizeEmail(_ email: String) -> String {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

test?

email.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
}
51 changes: 7 additions & 44 deletions Tests/WebTests/CrossmintAuthManagerTests.swift
Original file line number Diff line number Diff line change
@@ -1,68 +1,31 @@
import Foundation
import Testing
@testable import CrossmintAuth
import SecureStorage

@Suite("AuthManager", .tags(.unit))
struct CrossmintAuthManagerTests {
private let authManager = CrossmintAuthManager(
authService: StubAuthService(),
secureStorage: StubSecureStorage()
authService: MockAuthService(),
secureStorage: MockSecureStorage()
)

@Test("Throws invalidInput when OTP code is empty")
func throwsOnEmptyCode() async {
@Test func rejectsEmptyOtpCode() async {
await #expect(throws: AuthManagerError.invalidInput("OTP code cannot be empty")) {
_ = try await authManager.confirmEmailOtp(email: "user@example.com", code: "")
}
}

@Test("Throws invalidInput when OTP code is whitespace only")
func throwsOnWhitespaceCode() async {
@Test func rejectsWhitespaceOtpCode() async {
await #expect(throws: AuthManagerError.invalidInput("OTP code cannot be empty")) {
_ = try await authManager.confirmEmailOtp(email: "user@example.com", code: " ")
}
}

@Test("Does not throw when sending email OTP")
func doesNotThrowOnSendEmailOtp() async throws {
@Test func sendsOtpToValidEmail() async throws {
try await authManager.sendEmailOtp(email: "user@example.com")
}

@Test("Does not throw when OTP code is non-empty")
func doesNotThrowOnNonEmptyCode() async throws {
@Test func authenticatesWithValidOtpCode() async throws {
try await authManager.sendEmailOtp(email: "user@example.com")
_ = try await authManager.confirmEmailOtp(email: "user@example.com", code: "123456")
}
}

// MARK: - Test Doubles

private struct StubAuthService: AuthService {
func validateEmail(_ request: ValidateEmailRequest) async throws(AuthError) -> ValidateEmailResponse {
ValidateEmailResponse(emailId: "test-email-id")
}

func validateToken(_ request: ValidateTokenRequest) async throws(AuthError) -> ValidateTokenResponse {
ValidateTokenResponse(callbackUrl: "", oneTimeSecret: "test-secret")
}

func refreshJWT(_ request: RefreshJWTRequest) async throws(AuthError) -> RefreshJWTResponse {
RefreshJWTResponse(
jwt: "test-jwt",
refresh: .init(secret: "test-secret", expiresAt: Date().addingTimeInterval(3600)),
user: .init(id: "test-id", email: "user@example.com")
)
}

func logout(_ request: LogoutRequest) async throws(AuthError) {}
}

private struct StubSecureStorage: SecureStorage {
func getOneTimeSecret() async throws(SecureStorageError) -> String? { nil }
func storeOneTimeSecret(_ secret: String) async throws(SecureStorageError) {}
func getJWT() async throws(SecureStorageError) -> String? { nil }
func storeJWT(_ secret: String) async throws(SecureStorageError) {}
func getEmail() async throws(SecureStorageError) -> String? { nil }
func storeEmail(_ email: String) async throws(SecureStorageError) {}
func clear() {}
}
Loading
Loading