diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Supabase-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Supabase-Package.xcscheme
index 19580b48..b5dbf3bd 100644
--- a/.swiftpm/xcode/xcshareddata/xcschemes/Supabase-Package.xcscheme
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/Supabase-Package.xcscheme
@@ -20,6 +20,62 @@
ReferencedContainer = "container:">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
MFAStatus? {
+ do {
+ let aal = try await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
+ switch (aal.currentLevel, aal.nextLevel) {
+ case ("aal1", "aal1"):
+ return .unenrolled
+ case ("aal1", "aal2"):
+ return .unverified
+ case ("aal2", "aal2"):
+ return .verified
+ case ("aal2", "aal1"):
+ return .disabled
+ default:
+ return nil
+ }
+ } catch {
+ return nil
+ }
}
}
diff --git a/Examples/Examples/Info.plist b/Examples/Examples/Info.plist
new file mode 100644
index 00000000..e376e25d
--- /dev/null
+++ b/Examples/Examples/Info.plist
@@ -0,0 +1,17 @@
+
+
+
+
+ CFBundleURLTypes
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLSchemes
+
+ com.supabase.Examples://
+
+
+
+
+
diff --git a/Examples/Examples/MFAFlow.swift b/Examples/Examples/MFAFlow.swift
new file mode 100644
index 00000000..c89d88b0
--- /dev/null
+++ b/Examples/Examples/MFAFlow.swift
@@ -0,0 +1,207 @@
+//
+// MFAFlow.swift
+// Examples
+//
+// Created by Guilherme Souza on 27/10/23.
+//
+
+import SVGView
+import Supabase
+import SwiftUI
+
+enum MFAStatus {
+ case unenrolled
+ case unverified
+ case verified
+ case disabled
+
+ var description: String {
+ switch self {
+ case .unenrolled:
+ "User does not have MFA enrolled."
+ case .unverified:
+ "User has an MFA factor enrolled but has not verified it."
+ case .verified:
+ "User has verified their MFA factor."
+ case .disabled:
+ "User has disabled their MFA factor. (Stale JWT.)"
+ }
+ }
+}
+
+struct MFAFlow: View {
+ let status: MFAStatus
+
+ var body: some View {
+ NavigationStack {
+ switch status {
+ case .unenrolled:
+ MFAEnrollView()
+ case .unverified:
+ MFAVerifyView()
+ case .verified:
+ MFAVerifiedView()
+ case .disabled:
+ MFADisabledView()
+ }
+ }
+ }
+}
+
+struct MFAEnrollView: View {
+ @Environment(\.dismiss) private var dismiss
+ @State private var verificationCode = ""
+
+ @State private var enrollResponse: AuthMFAEnrollResponse?
+ @State private var error: Error?
+
+ var body: some View {
+ Form {
+ if let totp = enrollResponse?.totp {
+ Section {
+ SVGView(string: totp.qrCode)
+ LabeledContent("Secret", value: totp.secret)
+ LabeledContent("URI", value: totp.uri)
+ }
+ }
+
+ Section("Verification code") {
+ TextField("Code", text: $verificationCode)
+ }
+
+ if let error {
+ Section {
+ Text(error.localizedDescription).foregroundStyle(.red)
+ }
+ }
+ }
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel", role: .cancel) {
+ dismiss()
+ }
+ }
+
+ ToolbarItem(placement: .primaryAction) {
+ Button("Enable") {
+ enableButtonTapped()
+ }
+ .disabled(verificationCode.isEmpty)
+ }
+ }
+ .task {
+ do {
+ error = nil
+ enrollResponse = try await supabase.auth.mfa.enroll(params: MFAEnrollParams())
+ } catch {
+ self.error = error
+ }
+ }
+ }
+
+ @MainActor
+ private func enableButtonTapped() {
+ Task {
+ do {
+ try await supabase.auth.mfa.challengeAndVerify(
+ params: MFAChallengeAndVerifyParams(factorId: enrollResponse!.id, code: verificationCode))
+ } catch {
+ self.error = error
+ }
+ }
+ }
+}
+
+struct MFAVerifyView: View {
+ @Environment(\.dismiss) private var dismiss
+ @State private var verificationCode = ""
+ @State private var error: Error?
+
+ var body: some View {
+ Form {
+ Section {
+ TextField("Code", text: $verificationCode)
+ }
+
+ if let error {
+ Section {
+ Text(error.localizedDescription).foregroundStyle(.red)
+ }
+ }
+ }
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel", role: .cancel) {
+ dismiss()
+ }
+ }
+
+ ToolbarItem(placement: .primaryAction) {
+ Button("Verify") {
+ verifyButtonTapped()
+ }
+ .disabled(verificationCode.isEmpty)
+ }
+ }
+ }
+
+ @MainActor
+ private func verifyButtonTapped() {
+ Task {
+ do {
+ error = nil
+
+ let factors = try await supabase.auth.mfa.listFactors()
+ guard let totpFactor = factors.totp.first else {
+ debugPrint("No TOTP factor found.")
+ return
+ }
+
+ try await supabase.auth.mfa.challengeAndVerify(
+ params: MFAChallengeAndVerifyParams(factorId: totpFactor.id, code: verificationCode))
+ } catch {
+ self.error = error
+ }
+ }
+ }
+}
+
+struct MFAVerifiedView: View {
+ @EnvironmentObject var auth: AuthController
+
+ var factors: [Factor] {
+ auth.session?.user.factors ?? []
+ }
+
+ var body: some View {
+ List {
+ ForEach(factors) { factor in
+ VStack {
+ LabeledContent("ID", value: factor.id)
+ LabeledContent("Type", value: factor.factorType)
+ LabeledContent("Friendly name", value: factor.friendlyName ?? "-")
+ LabeledContent("Status", value: factor.status.rawValue)
+ }
+ }
+ .onDelete { indexSet in
+ Task {
+ do {
+ let factorsToRemove = indexSet.map { factors[$0] }
+ for factor in factorsToRemove {
+ try await supabase.auth.mfa.unenroll(params: MFAUnenrollParams(factorId: factor.id))
+ }
+ } catch {
+
+ }
+ }
+ }
+ }
+ .navigationTitle("Factors")
+ }
+}
+
+struct MFADisabledView: View {
+ var body: some View {
+ Text(MFAStatus.disabled.description)
+ }
+}
diff --git a/Examples/Examples/RootView.swift b/Examples/Examples/RootView.swift
index 246f424f..0d467887 100644
--- a/Examples/Examples/RootView.swift
+++ b/Examples/Examples/RootView.swift
@@ -9,29 +9,23 @@ import GoTrue
import SwiftUI
struct RootView: View {
- @State var authEvent: AuthChangeEvent?
- @State var handle: AuthStateListenerHandle?
-
@EnvironmentObject var auth: AuthController
var body: some View {
Group {
- if authEvent == .signedOut {
+ if auth.session == nil {
AuthView()
} else {
HomeView()
}
}
- .onAppear {
- handle = supabase.auth.onAuthStateChange { event, session in
- withAnimation {
- authEvent = event
- }
- auth.session = session
- }
+ .task {
+ await auth.observeAuth()
}
- .onDisappear {
- handle?.unsubscribe()
+ .onOpenURL { url in
+ Task {
+ try? await supabase.auth.session(from: url)
+ }
}
}
}
diff --git a/Examples/ProductSample/Data/AuthenticationRepository.swift b/Examples/ProductSample/Data/AuthenticationRepository.swift
index 8f38f7ee..b7f87fb7 100644
--- a/Examples/ProductSample/Data/AuthenticationRepository.swift
+++ b/Examples/ProductSample/Data/AuthenticationRepository.swift
@@ -32,7 +32,8 @@ struct AuthenticationRepositoryImpl: AuthenticationRepository {
switch event {
case .signedIn: AuthenticationState.signedIn
case .signedOut: AuthenticationState.signedOut
- case .passwordRecovery, .tokenRefreshed, .userUpdated, .userDeleted: nil
+ case .passwordRecovery, .tokenRefreshed, .userUpdated, .userDeleted, .mfaChallengeVerified:
+ nil
}
}
.eraseToStream()
diff --git a/Package.resolved b/Package.resolved
index 512e31a2..09041288 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -26,6 +26,15 @@
"revision" : "74203046135342e4a4a627476dd6caf8b28fe11b",
"version" : "509.0.0"
}
+ },
+ {
+ "identity" : "xctest-dynamic-overlay",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
+ "state" : {
+ "revision" : "23cbf2294e350076ea4dbd7d5d047c1e76b03631",
+ "version" : "1.0.2"
+ }
}
],
"version" : 2
diff --git a/Package.swift b/Package.swift
index 849d356c..2534ae83 100644
--- a/Package.swift
+++ b/Package.swift
@@ -26,6 +26,7 @@ let package = Package(
dependencies: [
.package(url: "https://github.com/kishikawakatsumi/KeychainAccess", from: "4.2.2"),
.package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.8.1"),
+ .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.0.0"),
],
targets: [
.target(name: "_Helpers"),
@@ -43,6 +44,7 @@ let package = Package(
dependencies: [
"GoTrue",
.product(name: "SnapshotTesting", package: "swift-snapshot-testing"),
+ .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"),
],
resources: [.process("Resources")]
),
diff --git a/Sources/GoTrue/GoTrueClient.swift b/Sources/GoTrue/GoTrueClient.swift
index 6782c12f..bf4549ee 100644
--- a/Sources/GoTrue/GoTrueClient.swift
+++ b/Sources/GoTrue/GoTrueClient.swift
@@ -7,23 +7,11 @@ public typealias AnyJSON = _Helpers.AnyJSON
import FoundationNetworking
#endif
-public struct AuthStateListenerHandle: Sendable {
- let id: UUID
- let onChange: @Sendable (AuthChangeEvent, Session?) -> Void
- let onUnsubscribe: @Sendable () -> Void
-
- public func unsubscribe() {
- onUnsubscribe()
- }
-}
-
public actor GoTrueClient {
- public typealias FetchHandler = @Sendable (_ request: URLRequest) async throws -> (
- Data,
- URLResponse
- )
+ public typealias FetchHandler =
+ @Sendable (_ request: URLRequest) async throws -> (Data, URLResponse)
- public struct Configuration {
+ public struct Configuration: Sendable {
public let url: URL
public var headers: [String: String]
public let flowType: AuthFlowType
@@ -41,6 +29,11 @@ public actor GoTrueClient {
decoder: JSONDecoder = .goTrue,
fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) }
) {
+ var headers = headers
+ if headers["X-Client-Info"] == nil {
+ headers["X-Client-Info"] = "gotrue-swift/\(version)"
+ }
+
self.url = url
self.headers = headers
self.flowType = flowType
@@ -56,11 +49,24 @@ public actor GoTrueClient {
}
}
- private let configuration: Configuration
- private let api: APIClient
- private let sessionManager: SessionManager
+ private var configuration: Configuration {
+ Dependencies.current.value!.configuration
+ }
+
+ private var api: APIClient {
+ Dependencies.current.value!.api
+ }
+
+ private var sessionManager: SessionManager {
+ Dependencies.current.value!.sessionManager
+ }
+
private let codeVerifierStorage: CodeVerifierStorage
+ private var eventEmitter: EventEmitter {
+ Dependencies.current.value!.eventEmitter
+ }
+
/// Returns the session, refreshing it if necessary.
public var session: Session {
get async throws {
@@ -68,12 +74,8 @@ public actor GoTrueClient {
}
}
- struct AuthChangeListener {
- var initialSessionTask: Task
- var continuation: AsyncStream.Continuation
- }
-
- private(set) var authChangeListeners: [UUID: AuthChangeListener] = [:]
+ /// Namespace for accessing multi-factor authentication API.
+ public let mfa: GoTrueMFA
public init(
url: URL,
@@ -98,44 +100,52 @@ public actor GoTrueClient {
}
public init(configuration: Configuration) {
+ let sessionManager = DefaultSessionManager()
+
+ let codeVerifierStorage = DefaultCodeVerifierStorage()
+ let api = APIClient()
+ let eventEmitter = DefaultEventEmitter()
+
self.init(
configuration: configuration,
- sessionManager: DefaultSessionManager(
- storage: DefaultSessionStorage(localStorage: configuration.localStorage)
- ),
- codeVerifierStorage: DefaultCodeVerifierStorage(localStorage: configuration.localStorage)
+ sessionManager: sessionManager,
+ codeVerifierStorage: codeVerifierStorage,
+ api: api,
+ eventEmitter: eventEmitter,
+ sessionStorage: .live
)
}
+ /// This internal initializer is here only for easy injecting mock instances when testing.
init(
configuration: Configuration,
sessionManager: SessionManager,
- codeVerifierStorage: CodeVerifierStorage
+ codeVerifierStorage: CodeVerifierStorage,
+ api: APIClient,
+ eventEmitter: EventEmitter,
+ sessionStorage: SessionStorage
) {
- var configuration = configuration
- configuration.headers["X-Client-Info"] = "gotrue-swift/\(version)"
- self.configuration = configuration
- self.api = APIClient(configuration: configuration, sessionManager: sessionManager)
- self.sessionManager = sessionManager
self.codeVerifierStorage = codeVerifierStorage
+ self.mfa = GoTrueMFA()
+
+ Dependencies.current.setValue(
+ Dependencies(
+ configuration: configuration,
+ sessionManager: sessionManager,
+ api: api,
+ eventEmitter: eventEmitter,
+ sessionStorage: sessionStorage,
+ sessionRefresher: SessionRefresher(
+ refreshSession: { [weak self] in
+ try await self?.refreshSession(refreshToken: $0) ?? .empty
+ }
+ )
+ )
+ )
}
- deinit {
- authChangeListeners.forEach {
- $0.value.continuation.finish()
- }
- }
-
- public func onAuthStateChange() -> AsyncStream {
- let id = UUID()
-
- let (stream, continuation) = AsyncStream.makeStream()
-
- continuation.onTermination = { [self, id] _ in
- Task(priority: .high) {
- await removeStream(at: id)
- }
- }
+ public func onAuthStateChange() async -> AsyncStream {
+ let (id, stream) = await eventEmitter.attachListener()
let emitInitialSessionTask = Task { [id] in
_debug("emitInitialSessionTask start")
@@ -143,18 +153,16 @@ public actor GoTrueClient {
await emitInitialSession(forStreamWithID: id)
}
- authChangeListeners[id] = AuthChangeListener(
- initialSessionTask: emitInitialSessionTask,
- continuation: continuation
- )
+ // TODO: store the emitInitialSessionTask somewhere, and cancel it when AsyncStream finishes.
+ //
+ // authChangeListeners[id] = AuthChangeListener(
+ // initialSessionTask: emitInitialSessionTask,
+ // continuation: continuation
+ // )
return stream
}
- private func removeStream(at id: UUID) {
- authChangeListeners[id] = nil
- }
-
/// Creates a new user.
/// - Parameters:
/// - email: User's email address.
@@ -247,7 +255,7 @@ public actor GoTrueClient {
if let session = response.session {
try await sessionManager.update(session)
- await emitAuthChangeEvent(.signedIn)
+ await eventEmitter.emit(.signedIn)
}
return response
@@ -307,7 +315,7 @@ public actor GoTrueClient {
if session.user.emailConfirmedAt != nil || session.user.confirmedAt != nil {
try await sessionManager.update(session)
- await emitAuthChangeEvent(.signedIn)
+ await eventEmitter.emit(.signedIn)
}
return session
@@ -410,7 +418,7 @@ public actor GoTrueClient {
try codeVerifierStorage.deleteCodeVerifier()
try await sessionManager.update(session)
- await emitAuthChangeEvent(.signedIn)
+ await eventEmitter.emit(.signedIn)
return session
} catch {
@@ -513,10 +521,10 @@ public actor GoTrueClient {
)
try await sessionManager.update(session)
- await emitAuthChangeEvent(.signedIn)
+ await eventEmitter.emit(.signedIn)
if let type = params.first(where: { $0.name == "type" })?.value, type == "recovery" {
- await emitAuthChangeEvent(.passwordRecovery)
+ await eventEmitter.emit(.passwordRecovery)
}
return session
@@ -565,7 +573,7 @@ public actor GoTrueClient {
}
try await sessionManager.update(session)
- await emitAuthChangeEvent(.tokenRefreshed)
+ await eventEmitter.emit(.tokenRefreshed)
return session
}
@@ -580,9 +588,9 @@ public actor GoTrueClient {
)
)
await sessionManager.remove()
- await emitAuthChangeEvent(.signedOut)
+ await eventEmitter.emit(.signedOut)
} catch {
- await emitAuthChangeEvent(.signedOut)
+ await eventEmitter.emit(.signedOut)
throw error
}
}
@@ -649,7 +657,7 @@ public actor GoTrueClient {
if let session = response.session {
try await sessionManager.update(session)
- await emitAuthChangeEvent(.signedIn)
+ await eventEmitter.emit(.signedIn)
}
return response
@@ -672,7 +680,7 @@ public actor GoTrueClient {
).decoded(as: User.self, decoder: configuration.decoder)
session.user = updatedUser
try await sessionManager.update(session)
- await emitAuthChangeEvent(.userUpdated)
+ await eventEmitter.emit(.userUpdated)
return updatedUser
}
@@ -699,27 +707,34 @@ public actor GoTrueClient {
)
}
- private func emitAuthChangeEvent(_ event: AuthChangeEvent) async {
- _debug("start")
- defer { _debug("end") }
+ @discardableResult
+ public func refreshSession(refreshToken: String) async throws -> Session {
+ let session = try await api.execute(
+ .init(
+ path: "/token",
+ method: "POST",
+ query: [URLQueryItem(name: "grant_type", value: "refresh_token")],
+ body: configuration.encoder.encode(UserCredentials(refreshToken: refreshToken))
+ )
+ ).decoded(as: Session.self, decoder: configuration.decoder)
- let listeners = authChangeListeners.values
- for listener in listeners {
- listener.continuation.yield(event)
+ if session.user.phoneConfirmedAt != nil || session.user.emailConfirmedAt != nil
+ || session
+ .user.confirmedAt != nil
+ {
+ try await sessionManager.update(session)
+ await eventEmitter.emit(.signedIn)
}
+
+ return session
}
private func emitInitialSession(forStreamWithID id: UUID) async {
_debug("start")
defer { _debug("end") }
- guard let continuation = authChangeListeners[id]?.continuation else {
- _debug("No continuation found for id: \(id)")
- return
- }
-
let session = try? await self.session
- continuation.yield(session != nil ? .signedIn : .signedOut)
+ await eventEmitter.emit(session != nil ? .signedIn : .signedOut, id: id)
}
private func _debug(
@@ -744,34 +759,6 @@ public actor GoTrueClient {
}
}
-extension GoTrueClient: SessionRefresher {
- @discardableResult
- public func refreshSession(refreshToken: String) async throws -> Session {
- do {
- let session = try await api.execute(
- .init(
- path: "/token",
- method: "POST",
- query: [URLQueryItem(name: "grant_type", value: "refresh_token")],
- body: configuration.encoder.encode(UserCredentials(refreshToken: refreshToken))
- )
- ).decoded(as: Session.self, decoder: configuration.decoder)
-
- if session.user.phoneConfirmedAt != nil || session.user.emailConfirmedAt != nil
- || session
- .user.confirmedAt != nil
- {
- try await sessionManager.update(session)
- await emitAuthChangeEvent(.signedIn)
- }
-
- return session
- } catch {
- throw error
- }
- }
-}
-
extension GoTrueClient {
public static let didChangeAuthStateNotification = Notification.Name(
"DID_CHANGE_AUTH_STATE_NOTIFICATION")
diff --git a/Sources/GoTrue/GoTrueMFA.swift b/Sources/GoTrue/GoTrueMFA.swift
new file mode 100644
index 00000000..fe3b2041
--- /dev/null
+++ b/Sources/GoTrue/GoTrueMFA.swift
@@ -0,0 +1,155 @@
+import Foundation
+@_spi(Internal) import _Helpers
+
+/// Contains the full multi-factor authentication API.
+public actor GoTrueMFA {
+ private var api: APIClient {
+ Dependencies.current.value!.api
+ }
+
+ private var sessionManager: SessionManager {
+ Dependencies.current.value!.sessionManager
+ }
+
+ private var configuration: GoTrueClient.Configuration {
+ Dependencies.current.value!.configuration
+ }
+
+ private var eventEmitter: EventEmitter {
+ Dependencies.current.value!.eventEmitter
+ }
+
+ /// Starts the enrollment process for a new Multi-Factor Authentication (MFA) factor. This method creates a new `unverified` factor.
+ /// To verify a factor, present the QR code or secret to the user and ask them to add it to their authenticator app.
+ /// The user has to enter the code from their authenticator app to verify it.
+ ///
+ /// Upon verifying a factor, all other sessions are logged out and the current session's authenticator level is promoted to `aal2`.
+ ///
+ /// - Parameter params: The parameters for enrolling a new MFA factor.
+ /// - Returns: An authentication response after enrolling the factor.
+ public func enroll(params: MFAEnrollParams) async throws -> AuthMFAEnrollResponse {
+ try await api.authorizedExecute(
+ Request(
+ path: "/factors", method: "POST",
+ body: configuration.encoder.encode(params)
+ )
+ )
+ .decoded(decoder: configuration.decoder)
+ }
+
+ /// Prepares a challenge used to verify that a user has access to a MFA factor.
+ ///
+ /// - Parameter params: The parameters for creating a challenge.
+ /// - Returns: An authentication response with the challenge information.
+ public func challenge(params: MFAChallengeParams) async throws -> AuthMFAChallengeResponse {
+ try await api.authorizedExecute(
+ Request(path: "/factors/\(params.factorId)/challenge", method: "POST")
+ )
+ .decoded(decoder: configuration.decoder)
+ }
+
+ /// Verifies a code against a challenge. The verification code is
+ /// provided by the user by entering a code seen in their authenticator app.
+ ///
+ /// - Parameter params: The parameters for verifying the MFA factor.
+ /// - Returns: An authentication response after verifying the factor.
+ public func verify(params: MFAVerifyParams) async throws -> AuthMFAVerifyResponse {
+ let response: AuthMFAVerifyResponse = try await api.authorizedExecute(
+ Request(
+ path: "/factors/\(params.factorId)/verify", method: "POST",
+ body: configuration.encoder.encode(params)
+ )
+ ).decoded(decoder: configuration.decoder)
+
+ try await sessionManager.update(response)
+
+ await eventEmitter.emit(.mfaChallengeVerified)
+
+ return response
+ }
+
+ /// Unenroll removes a MFA factor.
+ /// A user has to have an `aal2` authenticator level in order to unenroll a `verified` factor.
+ ///
+ /// - Parameter params: The parameters for unenrolling an MFA factor.
+ /// - Returns: An authentication response after unenrolling the factor.
+ @discardableResult
+ public func unenroll(params: MFAUnenrollParams) async throws -> AuthMFAUnenrollResponse {
+ try await api.authorizedExecute(
+ Request(path: "/factors/\(params.factorId)", method: "DELETE")
+ )
+ .decoded(decoder: configuration.decoder)
+ }
+
+ /// Helper method which creates a challenge and immediately uses the given code to verify against it thereafter. The verification code is
+ /// provided by the user by entering a code seen in their authenticator app.
+ ///
+ /// - Parameter params: The parameters for creating and verifying a challenge.
+ /// - Returns: An authentication response after verifying the challenge.
+ @discardableResult
+ public func challengeAndVerify(params: MFAChallengeAndVerifyParams) async throws
+ -> AuthMFAVerifyResponse
+ {
+ let response = try await challenge(params: MFAChallengeParams(factorId: params.factorId))
+ return try await verify(
+ params: MFAVerifyParams(
+ factorId: params.factorId, challengeId: response.id, code: params.code))
+ }
+
+ /// Returns the list of MFA factors enabled for this user.
+ ///
+ /// - Returns: An authentication response with the list of MFA factors.
+ public func listFactors() async throws -> AuthMFAListFactorsResponse {
+ let user = try await sessionManager.session().user
+ let factors = user.factors ?? []
+ let totp = factors.filter {
+ $0.factorType == "totp" && $0.status == .verified
+ }
+ return AuthMFAListFactorsResponse(all: factors, totp: totp)
+ }
+
+ /// Returns the Authenticator Assurance Level (AAL) for the active session.
+ ///
+ /// - Returns: An authentication response with the Authenticator Assurance Level.
+ public func getAuthenticatorAssuranceLevel() async throws
+ -> AuthMFAGetAuthenticatorAssuranceLevelResponse
+ {
+ do {
+ let session = try await sessionManager.session()
+ let payload = try decode(jwt: session.accessToken)
+
+ var currentLevel: AuthenticatorAssuranceLevels?
+
+ if let aal = payload["aal"] as? AuthenticatorAssuranceLevels {
+ currentLevel = aal
+ }
+
+ var nextLevel = currentLevel
+
+ let verifiedFactors = session.user.factors?.filter({ $0.status == .verified }) ?? []
+ if !verifiedFactors.isEmpty {
+ nextLevel = "aal2"
+ }
+
+ var currentAuthenticationMethods: [AMREntry] = []
+
+ if let amr = payload["amr"] as? [Any] {
+ currentAuthenticationMethods = amr.compactMap(AMREntry.init(value:))
+ }
+
+ return AuthMFAGetAuthenticatorAssuranceLevelResponse(
+ currentLevel: currentLevel,
+ nextLevel: nextLevel,
+ currentAuthenticationMethods: currentAuthenticationMethods
+ )
+ } catch GoTrueError.sessionNotFound {
+ return AuthMFAGetAuthenticatorAssuranceLevelResponse(
+ currentLevel: nil,
+ nextLevel: nil,
+ currentAuthenticationMethods: []
+ )
+ } catch {
+ throw error
+ }
+ }
+}
diff --git a/Sources/GoTrue/Internal/APIClient.swift b/Sources/GoTrue/Internal/APIClient.swift
index 2ba0664b..83e35b4f 100644
--- a/Sources/GoTrue/Internal/APIClient.swift
+++ b/Sources/GoTrue/Internal/APIClient.swift
@@ -2,13 +2,12 @@ import Foundation
@_spi(Internal) import _Helpers
actor APIClient {
+ private var configuration: GoTrueClient.Configuration {
+ Dependencies.current.value!.configuration
+ }
- private let configuration: GoTrueClient.Configuration
- private let sessionManager: SessionManager
-
- init(configuration: GoTrueClient.Configuration, sessionManager: SessionManager) {
- self.configuration = configuration
- self.sessionManager = sessionManager
+ private var sessionManager: SessionManager {
+ Dependencies.current.value!.sessionManager
}
@discardableResult
diff --git a/Sources/GoTrue/Internal/CodeVerifierStorage.swift b/Sources/GoTrue/Internal/CodeVerifierStorage.swift
index 5b966865..a55b8d8e 100644
--- a/Sources/GoTrue/Internal/CodeVerifierStorage.swift
+++ b/Sources/GoTrue/Internal/CodeVerifierStorage.swift
@@ -1,11 +1,5 @@
-//
-// File.swift
-//
-//
-// Created by Guilherme Souza on 24/10/23.
-//
-
import Foundation
+@_spi(Internal) import _Helpers
protocol CodeVerifierStorage {
func getCodeVerifier() throws -> String?
@@ -14,7 +8,10 @@ protocol CodeVerifierStorage {
}
struct DefaultCodeVerifierStorage: CodeVerifierStorage {
- let localStorage: GoTrueLocalStorage
+ private var localStorage: GoTrueLocalStorage {
+ Dependencies.current.value!.configuration.localStorage
+ }
+
private let key = "supabase.code-verifier"
func getCodeVerifier() throws -> String? {
diff --git a/Sources/GoTrue/Internal/Dependencies.swift b/Sources/GoTrue/Internal/Dependencies.swift
new file mode 100644
index 00000000..720a3c7f
--- /dev/null
+++ b/Sources/GoTrue/Internal/Dependencies.swift
@@ -0,0 +1,13 @@
+import Foundation
+@_spi(Internal) import _Helpers
+
+struct Dependencies: Sendable {
+ static let current = LockIsolated(Dependencies?.none)
+
+ var configuration: GoTrueClient.Configuration
+ var sessionManager: SessionManager
+ var api: APIClient
+ var eventEmitter: EventEmitter
+ var sessionStorage: SessionStorage
+ var sessionRefresher: SessionRefresher
+}
diff --git a/Sources/GoTrue/Internal/EventEmitter.swift b/Sources/GoTrue/Internal/EventEmitter.swift
new file mode 100644
index 00000000..defc5713
--- /dev/null
+++ b/Sources/GoTrue/Internal/EventEmitter.swift
@@ -0,0 +1,53 @@
+import Foundation
+@_spi(Internal) import _Helpers
+
+protocol EventEmitter: Sendable {
+ func attachListener() async -> (id: UUID, stream: AsyncStream)
+ func emit(_ event: AuthChangeEvent, id: UUID?) async
+}
+
+extension EventEmitter {
+ func emit(_ event: AuthChangeEvent) async {
+ await emit(event, id: nil)
+ }
+}
+
+actor DefaultEventEmitter: EventEmitter {
+ deinit {
+ continuations.values.forEach {
+ $0.finish()
+ }
+ }
+
+ private(set) var continuations: [UUID: AsyncStream.Continuation] = [:]
+
+ func attachListener() -> (id: UUID, stream: AsyncStream) {
+ let id = UUID()
+
+ let (stream, continuation) = AsyncStream.makeStream()
+
+ continuation.onTermination = { [self, id] _ in
+ Task(priority: .high) {
+ await removeStream(at: id)
+ }
+ }
+
+ continuations[id] = continuation
+
+ return (id, stream)
+ }
+
+ func emit(_ event: AuthChangeEvent, id: UUID? = nil) {
+ if let id {
+ continuations[id]?.yield(event)
+ } else {
+ for continuation in continuations.values {
+ continuation.yield(event)
+ }
+ }
+ }
+
+ private func removeStream(at id: UUID) {
+ self.continuations[id] = nil
+ }
+}
diff --git a/Sources/GoTrue/Internal/SessionManager.swift b/Sources/GoTrue/Internal/SessionManager.swift
index 0bdcf155..5adc6e9d 100644
--- a/Sources/GoTrue/Internal/SessionManager.swift
+++ b/Sources/GoTrue/Internal/SessionManager.swift
@@ -1,12 +1,12 @@
import Foundation
import KeychainAccess
+@_spi(Internal) import _Helpers
-protocol SessionRefresher: AnyObject {
- func refreshSession(refreshToken: String) async throws -> Session
+struct SessionRefresher: Sendable {
+ var refreshSession: @Sendable (_ refreshToken: String) async throws -> Session
}
protocol SessionManager: Sendable {
- func setSessionRefresher(_ refresher: SessionRefresher?) async
func session() async throws -> Session
func update(_ session: Session) async throws
func remove() async
@@ -14,16 +14,13 @@ protocol SessionManager: Sendable {
actor DefaultSessionManager: SessionManager {
private var task: Task?
- private let storage: SessionStorage
- private weak var sessionRefresher: SessionRefresher?
-
- init(storage: SessionStorage) {
- self.storage = storage
+ private var storage: SessionStorage {
+ Dependencies.current.value!.sessionStorage
}
- func setSessionRefresher(_ refresher: SessionRefresher?) {
- sessionRefresher = refresher
+ private var sessionRefresher: SessionRefresher {
+ Dependencies.current.value!.sessionRefresher
}
func session() async throws -> Session {
@@ -42,14 +39,9 @@ actor DefaultSessionManager: SessionManager {
task = Task {
defer { task = nil }
- if let session = try await sessionRefresher?.refreshSession(
- refreshToken: currentSession.session.refreshToken)
- {
- try update(session)
- return session
- }
-
- throw GoTrueError.sessionNotFound
+ let session = try await sessionRefresher.refreshSession(currentSession.session.refreshToken)
+ try update(session)
+ return session
}
return try await task!.value
@@ -60,6 +52,6 @@ actor DefaultSessionManager: SessionManager {
}
func remove() {
- storage.deleteSession()
+ try? storage.deleteSession()
}
}
diff --git a/Sources/GoTrue/Internal/SessionStorage.swift b/Sources/GoTrue/Internal/SessionStorage.swift
index 3b9e4d8a..fda73986 100644
--- a/Sources/GoTrue/Internal/SessionStorage.swift
+++ b/Sources/GoTrue/Internal/SessionStorage.swift
@@ -6,6 +6,7 @@
//
import Foundation
+@_spi(Internal) import _Helpers
/// A locally stored ``Session``, it contains metadata such as `expirationDate`.
struct StoredSession: Codable {
@@ -22,26 +23,30 @@ struct StoredSession: Codable {
}
}
-protocol SessionStorage {
- func getSession() throws -> StoredSession?
- func storeSession(_ session: StoredSession) throws
- func deleteSession()
+struct SessionStorage: Sendable {
+ var getSession: @Sendable () throws -> StoredSession?
+ var storeSession: @Sendable (_ session: StoredSession) throws -> Void
+ var deleteSession: @Sendable () throws -> Void
}
-struct DefaultSessionStorage: SessionStorage {
- let localStorage: GoTrueLocalStorage
-
- func getSession() throws -> StoredSession? {
- try localStorage.retrieve(key: "supabase.session").flatMap {
- try JSONDecoder.goTrue.decode(StoredSession.self, from: $0)
+extension SessionStorage {
+ static var live: Self = {
+ var localStorage: GoTrueLocalStorage {
+ Dependencies.current.value!.configuration.localStorage
}
- }
- func storeSession(_ session: StoredSession) throws {
- try localStorage.store(key: "supabase.session", value: JSONEncoder.goTrue.encode(session))
- }
-
- func deleteSession() {
- try? localStorage.remove(key: "supabase.session")
- }
+ return Self(
+ getSession: {
+ try localStorage.retrieve(key: "supabase.session").flatMap {
+ try JSONDecoder.goTrue.decode(StoredSession.self, from: $0)
+ }
+ },
+ storeSession: {
+ try localStorage.store(key: "supabase.session", value: JSONEncoder.goTrue.encode($0))
+ },
+ deleteSession: {
+ try localStorage.remove(key: "supabase.session")
+ }
+ )
+ }()
}
diff --git a/Sources/GoTrue/Types.swift b/Sources/GoTrue/Types.swift
index 804645d1..f51f8f3a 100644
--- a/Sources/GoTrue/Types.swift
+++ b/Sources/GoTrue/Types.swift
@@ -7,6 +7,7 @@ public enum AuthChangeEvent: String, Sendable {
case tokenRefreshed = "TOKEN_REFRESHED"
case userUpdated = "USER_UPDATED"
case userDeleted = "USER_DELETED"
+ case mfaChallengeVerified = "MFA_CHALLENGE_VERIFIED"
}
public struct UserCredentials: Codable, Hashable, Sendable {
@@ -74,6 +75,21 @@ public struct Session: Codable, Hashable, Sendable {
self.refreshToken = refreshToken
self.user = user
}
+
+ static let empty = Session(
+ accessToken: "",
+ tokenType: "",
+ expiresIn: 0,
+ refreshToken: "",
+ user: User(
+ id: UUID(),
+ appMetadata: [:],
+ userMetadata: [:],
+ aud: "",
+ createdAt: Date(),
+ updatedAt: Date()
+ )
+ )
}
public struct User: Codable, Hashable, Identifiable, Sendable {
@@ -97,6 +113,7 @@ public struct User: Codable, Hashable, Identifiable, Sendable {
public var role: String?
public var updatedAt: Date
public var identities: [UserIdentity]?
+ public var factors: [Factor]?
public init(
id: UUID,
@@ -118,7 +135,8 @@ public struct User: Codable, Hashable, Identifiable, Sendable {
lastSignInAt: Date? = nil,
role: String? = nil,
updatedAt: Date,
- identities: [UserIdentity]? = nil
+ identities: [UserIdentity]? = nil,
+ factors: [Factor]? = nil
) {
self.id = id
self.appMetadata = appMetadata
@@ -140,6 +158,7 @@ public struct User: Codable, Hashable, Identifiable, Sendable {
self.role = role
self.updatedAt = updatedAt
self.identities = identities
+ self.factors = factors
}
}
@@ -332,11 +351,172 @@ struct RecoverParams: Codable, Hashable, Sendable {
var gotrueMetaSecurity: GoTrueMetaSecurity?
}
-public enum AuthFlowType {
+public enum AuthFlowType: Sendable {
case implicit
case pkce
}
+public typealias FactorType = String
+
+public enum FactorStatus: String, Codable, Sendable {
+ case verified
+ case unverified
+}
+
+/// An MFA Factor.
+public struct Factor: Identifiable, Codable, Hashable, Sendable {
+ /// ID of the factor.
+ public let id: String
+
+ /// Friendly name of the factor, useful to disambiguate between multiple factors.
+ public let friendlyName: String?
+
+ /// Type of factor. Only `totp` supported with this version but may change in future versions.
+ public let factorType: String
+
+ /// Factor's status.
+ public let status: FactorStatus
+
+ public let createdAt: Date
+ public let updatedAt: Date
+}
+
+public struct MFAEnrollParams: Encodable, Hashable, Sendable {
+ public let factorType: FactorType = "totp"
+ /// Domain which the user is enrolled with.
+ public let issuer: String?
+ /// Human readable name assigned to the factor.
+ public let friendlyName: String?
+
+ public init(issuer: String? = nil, friendlyName: String? = nil) {
+ self.issuer = issuer
+ self.friendlyName = friendlyName
+ }
+}
+
+public struct AuthMFAEnrollResponse: Decodable, Hashable, Sendable {
+ /// ID of the factor that was just enrolled (in an unverified state).
+ public let id: String
+
+ /// Type of MFA factor. Only `totp` supported for now.
+ public let type: FactorType
+
+ /// TOTP enrollment information.
+ public var totp: TOTP?
+
+ public struct TOTP: Decodable, Hashable, Sendable {
+ /// Contains a QR code encoding the authenticator URI. You can convert it to a URL by prepending `data:image/svg+xml;utf-8,` to the value. Avoid logging this value to the console.
+ public var qrCode: String
+
+ /// The TOTP secret (also encoded in the QR code). Show this secret in a password-style field to the user, in case they are unable to scan the QR code. Avoid logging this value to the console.
+ public let secret: String
+
+ /// The authenticator URI encoded within the QR code, should you need to use it. Avoid logging this value to the console.
+ public let uri: String
+ }
+}
+
+public struct MFAChallengeParams: Encodable, Hashable {
+ /// ID of the factor to be challenged. Returned in ``GoTrueMFA.enroll(params:)``.
+ public let factorId: String
+}
+
+public struct MFAVerifyParams: Encodable, Hashable {
+ /// ID of the factor being verified. Returned in ``GoTrueMFA.enroll(params:)``.
+ public let factorId: String
+
+ /// ID of the challenge being verified. Returned in challenge().
+ public let challengeId: String
+
+ /// Verification code provided by the user.
+ public let code: String
+}
+
+public struct MFAUnenrollParams: Encodable, Hashable, Sendable {
+ /// ID of the factor to unenroll. Returned in ``GoTrueMFA.enroll(params:)``.
+ public let factorId: String
+
+ public init(factorId: String) {
+ self.factorId = factorId
+ }
+}
+
+public struct MFAChallengeAndVerifyParams: Encodable, Hashable, Sendable {
+ /// ID of the factor to be challenged. Returned in ``GoTrueMFA.enroll(params:)``.
+ public let factorId: String
+
+ /// Verification code provided by the user.
+ public let code: String
+
+ public init(factorId: String, code: String) {
+ self.factorId = factorId
+ self.code = code
+ }
+}
+
+public struct AuthMFAChallengeResponse: Decodable, Hashable, Sendable {
+ /// ID of the newly created challenge.
+ public let id: String
+
+ /// Timestamp in UNIX seconds when this challenge will no longer be usable.
+ public let expiresAt: TimeInterval
+}
+
+public typealias AuthMFAVerifyResponse = Session
+
+public struct AuthMFAUnenrollResponse: Decodable, Hashable, Sendable {
+ /// ID of the factor that was successfully unenrolled.
+ public let factorId: String
+}
+
+public struct AuthMFAListFactorsResponse: Decodable, Hashable, Sendable {
+ /// All available factors (verified and unverified).
+ public let all: [Factor]
+
+ /// Only verified TOTP factors. (A subset of `all`.)
+ public let totp: [Factor]
+}
+
+public typealias AuthenticatorAssuranceLevels = String
+
+/// An authentication method reference (AMR) entry.
+///
+/// An entry designates what method was used by the user to verify their identity and at what time.
+public struct AMREntry: Decodable, Hashable, Sendable {
+ /// Authentication method name.
+ public let method: Method
+
+ /// Timestamp when the method was successfully used.
+ public let timestamp: TimeInterval
+
+ public typealias Method = String
+}
+
+extension AMREntry {
+ init?(value: Any) {
+ guard let dict = value as? [String: Any],
+ let method = dict["method"] as? Method,
+ let timestamp = dict["timestamp"] as? TimeInterval
+ else {
+ return nil
+ }
+
+ self.method = method
+ self.timestamp = timestamp
+ }
+}
+
+public struct AuthMFAGetAuthenticatorAssuranceLevelResponse: Decodable, Hashable, Sendable {
+ /// Current AAL level of the session.
+ public let currentLevel: AuthenticatorAssuranceLevels?
+
+ /// Next possible AAL level for the session. If the next level is higher than the current one, the user should go through MFA.
+ public let nextLevel: AuthenticatorAssuranceLevels?
+
+ /// A list of all authentication methods attached to this session. Use the information here to detect the last time a user verified a factor, for example if implementing a step-up scenario.
+ public let currentAuthenticationMethods: [AMREntry]
+}
+
// MARK: - Encodable & Decodable
private let dateFormatterWithFractionalSeconds = { () -> ISO8601DateFormatter in
diff --git a/Sources/_Helpers/AsyncStream.swift b/Sources/_Helpers/AsyncStream.swift
index 19eed7b5..71c08ed4 100644
--- a/Sources/_Helpers/AsyncStream.swift
+++ b/Sources/_Helpers/AsyncStream.swift
@@ -8,14 +8,14 @@
import Foundation
extension AsyncStream {
-#if compiler(<5.9)
- @_spi(Internal)
- public static func makeStream(
- of elementType: Element.Type = Element.self,
- bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded
- ) -> (stream: Self, continuation: Continuation) {
- var continuation: Continuation!
- return (Self(elementType, bufferingPolicy: limit) { continuation = $0 }, continuation)
- }
-#endif
+ #if compiler(<5.9)
+ @_spi(Internal)
+ public static func makeStream(
+ of elementType: Element.Type = Element.self,
+ bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded
+ ) -> (stream: Self, continuation: Continuation) {
+ var continuation: Continuation!
+ return (Self(elementType, bufferingPolicy: limit) { continuation = $0 }, continuation)
+ }
+ #endif
}
diff --git a/Sources/_Helpers/Task.swift b/Sources/_Helpers/Task.swift
index 24bc6344..0d76e50a 100644
--- a/Sources/_Helpers/Task.swift
+++ b/Sources/_Helpers/Task.swift
@@ -1,6 +1,6 @@
//
// File.swift
-//
+//
//
// Created by Guilherme Souza on 26/10/23.
//
diff --git a/Tests/GoTrueTests/GoTrueClientTests.swift b/Tests/GoTrueTests/GoTrueClientTests.swift
index 657061dc..977fa4c9 100644
--- a/Tests/GoTrueTests/GoTrueClientTests.swift
+++ b/Tests/GoTrueTests/GoTrueClientTests.swift
@@ -14,6 +14,8 @@ final class GoTrueClientTests: XCTestCase {
fileprivate var sessionManager: SessionManagerMock!
fileprivate var codeVerifierStorage: CodeVerifierStorageMock!
+ fileprivate var eventEmitter: DefaultEventEmitter!
+ fileprivate var api: APIClient!
func testOnAuthStateChange() async throws {
let session = Session.validSession
@@ -36,38 +38,39 @@ final class GoTrueClientTests: XCTestCase {
}
}
- var listeners = await sut.authChangeListeners
- XCTAssertEqual(listeners.count, 1)
-
await fulfillment(of: [expectation])
XCTAssertEqual(events.value, [.signedIn])
streamTask.cancel()
-
- await Task.megaYield()
-
- listeners = await sut.authChangeListeners
- XCTAssertEqual(listeners.count, 0)
}
private func makeSUT(fetch: GoTrueClient.FetchHandler? = nil) -> GoTrueClient {
sessionManager = SessionManagerMock()
codeVerifierStorage = CodeVerifierStorageMock()
- let sut = GoTrueClient(
- configuration: GoTrueClient.Configuration(
- url: clientURL,
- headers: ["apikey": "dummy.api.key"],
- fetch: { request in
- if let fetch {
- return try await fetch(request)
- }
-
- throw UnimplementedError()
+ eventEmitter = DefaultEventEmitter()
+
+ let configuration = GoTrueClient.Configuration(
+ url: clientURL,
+ headers: ["apikey": "dummy.api.key"],
+ fetch: { request in
+ if let fetch {
+ return try await fetch(request)
}
- ),
+
+ throw UnimplementedError()
+ }
+ )
+
+ api = APIClient()
+
+ let sut = GoTrueClient(
+ configuration: configuration,
sessionManager: sessionManager,
- codeVerifierStorage: codeVerifierStorage
+ codeVerifierStorage: codeVerifierStorage,
+ api: api,
+ eventEmitter: eventEmitter,
+ sessionStorage: .mock
)
addTeardownBlock { [weak sut] in
@@ -81,7 +84,7 @@ final class GoTrueClientTests: XCTestCase {
private final class SessionManagerMock: SessionManager, @unchecked Sendable {
private let lock = NSRecursiveLock()
- weak var sessionRefresher: SessionRefresher?
+ var sessionRefresher: SessionRefresher?
func setSessionRefresher(_ refresher: GoTrue.SessionRefresher?) async {
lock.withLock {
sessionRefresher = refresher
diff --git a/Tests/GoTrueTests/RequestsTests.swift b/Tests/GoTrueTests/RequestsTests.swift
index 20fb5e1f..a2d0ac57 100644
--- a/Tests/GoTrueTests/RequestsTests.swift
+++ b/Tests/GoTrueTests/RequestsTests.swift
@@ -7,15 +7,13 @@
import SnapshotTesting
import XCTest
+@_spi(Internal) import _Helpers
@testable import GoTrue
struct UnimplementedError: Error {}
final class RequestsTests: XCTestCase {
-
- var storage: SessionStorageMock!
-
func testSignUpWithEmailAndPassword() async {
let sut = makeSUT()
@@ -134,20 +132,24 @@ final class RequestsTests: XCTestCase {
return (json(named: "user"), HTTPURLResponse())
})
- let url = URL(
- string:
- "https://dummy-url.com/callback#access_token=accesstoken&expires_in=60&refresh_token=refreshtoken&token_type=bearer"
- )!
+ try await withDependencies {
+ $0.sessionStorage.storeSession = { _ in }
+ } operation: {
+ let url = URL(
+ string:
+ "https://dummy-url.com/callback#access_token=accesstoken&expires_in=60&refresh_token=refreshtoken&token_type=bearer"
+ )!
- let session = try await sut.session(from: url)
- let expectedSession = Session(
- accessToken: "accesstoken",
- tokenType: "bearer",
- expiresIn: 60,
- refreshToken: "refreshtoken",
- user: User(fromMockNamed: "user")
- )
- XCTAssertEqual(session, expectedSession)
+ let session = try await sut.session(from: url)
+ let expectedSession = Session(
+ accessToken: "accesstoken",
+ tokenType: "bearer",
+ expiresIn: 60,
+ refreshToken: "refreshtoken",
+ user: User(fromMockNamed: "user")
+ )
+ XCTAssertEqual(session, expectedSession)
+ }
}
func testSessionFromURLWithMissingComponent() async {
@@ -168,13 +170,18 @@ final class RequestsTests: XCTestCase {
func testSetSessionWithAFutureExpirationDate() async throws {
let sut = makeSUT()
- storage.session = .success(.init(session: .validSession))
-
- let accessToken =
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjo0ODUyMTYzNTkzLCJzdWIiOiJmMzNkM2VjOS1hMmVlLTQ3YzQtODBlMS01YmQ5MTlmM2Q4YjgiLCJlbWFpbCI6ImhpQGJpbmFyeXNjcmFwaW5nLmNvIiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCJdfSwidXNlcl9tZXRhZGF0YSI6e30sInJvbGUiOiJhdXRoZW50aWNhdGVkIn0.UiEhoahP9GNrBKw_OHBWyqYudtoIlZGkrjs7Qa8hU7I"
- await assert {
- try await sut.setSession(accessToken: accessToken, refreshToken: "dummy-refresh-token")
+ await withDependencies {
+ $0.sessionStorage.getSession = {
+ .init(session: .validSession)
+ }
+ } operation: {
+ let accessToken =
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjo0ODUyMTYzNTkzLCJzdWIiOiJmMzNkM2VjOS1hMmVlLTQ3YzQtODBlMS01YmQ5MTlmM2Q4YjgiLCJlbWFpbCI6ImhpQGJpbmFyeXNjcmFwaW5nLmNvIiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCJdfSwidXNlcl9tZXRhZGF0YSI6e30sInJvbGUiOiJhdXRoZW50aWNhdGVkIn0.UiEhoahP9GNrBKw_OHBWyqYudtoIlZGkrjs7Qa8hU7I"
+
+ await assert {
+ try await sut.setSession(accessToken: accessToken, refreshToken: "dummy-refresh-token")
+ }
}
}
@@ -191,9 +198,14 @@ final class RequestsTests: XCTestCase {
func testSignOut() async {
let sut = makeSUT()
- storage.session = .success(.init(session: .validSession))
- await assert {
- try await sut.signOut()
+ await withDependencies {
+ $0.sessionStorage.getSession = {
+ .init(session: .validSession)
+ }
+ } operation: {
+ await assert {
+ try await sut.signOut()
+ }
}
}
@@ -224,17 +236,23 @@ final class RequestsTests: XCTestCase {
func testUpdateUser() async throws {
let sut = makeSUT()
- storage.session = .success(StoredSession(session: .validSession))
- await assert {
- try await sut.update(
- user: UserAttributes(
- email: "example@mail.com",
- phone: "+1 202-918-2132",
- password: "another.pass",
- emailChangeToken: "123456",
- data: ["custom_key": .string("custom_value")]
+
+ await withDependencies {
+ $0.sessionStorage.getSession = {
+ .init(session: .validSession)
+ }
+ } operation: {
+ await assert {
+ try await sut.update(
+ user: UserAttributes(
+ email: "example@mail.com",
+ phone: "+1 202-918-2132",
+ password: "another.pass",
+ emailChangeToken: "123456",
+ data: ["custom_key": .string("custom_value")]
+ )
)
- )
+ }
}
}
@@ -265,30 +283,44 @@ final class RequestsTests: XCTestCase {
testName: String = #function,
line: UInt = #line
) -> GoTrueClient {
- storage = SessionStorageMock()
+ var storage = SessionStorage.mock
+ storage.deleteSession = {}
+
let encoder = JSONEncoder.goTrue
encoder.outputFormatting = .sortedKeys
- return GoTrueClient(
- configuration: GoTrueClient.Configuration(
- url: clientURL,
- headers: ["apikey": "dummy.api.key"],
- encoder: encoder,
- fetch: { request in
- DispatchQueue.main.sync {
- assertSnapshot(
- of: request, as: .curl, record: record, file: file, testName: testName, line: line)
- }
-
- if let fetch {
- return try await fetch(request)
- }
-
- throw UnimplementedError()
+ let sessionManager = DefaultSessionManager()
+
+ let configuration = GoTrueClient.Configuration(
+ url: clientURL,
+ headers: ["apikey": "dummy.api.key"],
+ encoder: encoder,
+ fetch: { request in
+ DispatchQueue.main.sync {
+ assertSnapshot(
+ of: request, as: .curl, record: record, file: file, testName: testName, line: line)
}
- ),
- sessionManager: DefaultSessionManager(storage: storage),
- codeVerifierStorage: CodeVerifierStorageMock()
+
+ if let fetch {
+ return try await fetch(request)
+ }
+
+ throw UnimplementedError()
+ }
+ )
+
+ let eventEmitter = DefaultEventEmitter()
+
+ // TODO: Inject a mocked APIClient
+ let api = APIClient()
+
+ return GoTrueClient(
+ configuration: configuration,
+ sessionManager: sessionManager,
+ codeVerifierStorage: CodeVerifierStorageMock(),
+ api: api,
+ eventEmitter: eventEmitter,
+ sessionStorage: storage
)
}
}
diff --git a/Tests/GoTrueTests/SessionManagerTests.swift b/Tests/GoTrueTests/SessionManagerTests.swift
index 40cd20da..7a3d96b8 100644
--- a/Tests/GoTrueTests/SessionManagerTests.swift
+++ b/Tests/GoTrueTests/SessionManagerTests.swift
@@ -6,94 +6,122 @@
//
import XCTest
+import XCTestDynamicOverlay
+@_spi(Internal) import _Helpers
@testable import GoTrue
final class SessionManagerTests: XCTestCase {
+ override func setUp() {
+ super.setUp()
+
+ Dependencies.current.setValue(.mock)
+ }
func testSession_shouldFailWithSessionNotFound() async {
- let mock = SessionStorageMock()
- let sut = DefaultSessionManager(storage: mock)
-
- do {
- _ = try await sut.session()
- XCTFail("Expected a \(GoTrueError.sessionNotFound) failure")
- } catch GoTrueError.sessionNotFound {
- } catch {
- XCTFail("Unexpected error \(error)")
+ await withDependencies {
+ $0.sessionStorage.getSession = { nil }
+ } operation: {
+ let sut = DefaultSessionManager()
+
+ do {
+ _ = try await sut.session()
+ XCTFail("Expected a \(GoTrueError.sessionNotFound) failure")
+ } catch GoTrueError.sessionNotFound {
+ } catch {
+ XCTFail("Unexpected error \(error)")
+ }
}
}
func testSession_shouldReturnValidSession() async throws {
- let mock = SessionStorageMock()
- mock.session = .success(.init(session: .validSession))
-
- let sut = DefaultSessionManager(storage: mock)
+ try await withDependencies {
+ $0.sessionStorage.getSession = {
+ .init(session: .validSession)
+ }
+ } operation: {
+ let sut = DefaultSessionManager()
- let session = try await sut.session()
- XCTAssertEqual(session, .validSession)
+ let session = try await sut.session()
+ XCTAssertEqual(session, .validSession)
+ }
}
func testSession_shouldRefreshSession_whenCurrentSessionExpired() async throws {
let currentSession = Session.expiredSession
let validSession = Session.validSession
- let mock = SessionStorageMock()
- mock.session = .success(.init(session: currentSession))
+ let storeSessionCallCount = ActorIsolated(0)
+ let refreshSessionCallCount = ActorIsolated(0)
- let sut = DefaultSessionManager(storage: mock)
-
- let sessionRefresher = SessionRefresherMock()
- sessionRefresher.refreshSessionHandler = { refreshToken in
- return validSession
- }
-
- await sut.setSessionRefresher(sessionRefresher)
+ try await withDependencies {
+ $0.sessionStorage.getSession = {
+ .init(session: currentSession)
+ }
+ $0.sessionStorage.storeSession = { _ in
+ storeSessionCallCount.withValue {
+ $0 += 1
+ }
+ }
+ $0.sessionRefresher.refreshSession = { refreshToken in
+ refreshSessionCallCount.withValue { $0 += 1 }
+ return validSession
+ }
+ } operation: {
+ let sut = DefaultSessionManager()
+
+ // Fire N tasks and call sut.session()
+ let tasks = (0..<10).map { _ in
+ Task.detached {
+ try await sut.session()
+ }
+ }
- // Fire N tasks and call sut.session()
- let tasks = (0..<10).map { _ in
- Task.detached {
- try await sut.session()
+ // Await for all tasks to complete.
+ var result: [Result] = []
+ for task in tasks {
+ let value = await task.result
+ result.append(value)
}
- }
- // Await for all tasks to complete.
- var result: [Result] = []
- for task in tasks {
- let value = await task.result
- result.append(value)
+ // Verify that refresher and storage was called only once.
+ XCTAssertEqual(refreshSessionCallCount.value, 1)
+ XCTAssertEqual(storeSessionCallCount.value, 1)
+ XCTAssertEqual(try result.map { try $0.get() }, (0..<10).map { _ in validSession })
}
-
- // Verify that refresher and storage was called only once.
- XCTAssertEqual(sessionRefresher.refreshSessionCallCount, 1)
- XCTAssertEqual(mock.storeSessionCallCount, 1)
- XCTAssertEqual(try result.map { try $0.get() }, (0..<10).map { _ in validSession })
}
}
-class SessionStorageMock: SessionStorage {
- var session: Result?
-
- func getSession() throws -> StoredSession? {
- try session?.get()
- }
+extension SessionStorage {
+ static let mock = Self(
+ getSession: unimplemented("getSession"),
+ storeSession: unimplemented("storeSession"),
+ deleteSession: unimplemented("deleteSession")
+ )
+}
- var storeSessionCallCount = 0
- func storeSession(_ session: StoredSession) throws {
- storeSessionCallCount += 1
- self.session = .success(session)
- }
+extension SessionRefresher {
+ static let mock = Self(refreshSession: unimplemented("refreshSession"))
+}
- func deleteSession() {
- session = nil
- }
+extension Dependencies {
+ static let mock = Dependencies(
+ configuration: GoTrueClient.Configuration(url: clientURL),
+ sessionManager: DefaultSessionManager(),
+ api: APIClient(),
+ eventEmitter: DefaultEventEmitter(),
+ sessionStorage: .mock,
+ sessionRefresher: .mock
+ )
}
-class SessionRefresherMock: SessionRefresher {
- var refreshSessionCallCount = 0
- var refreshSessionHandler: ((String) async throws -> Session)!
- func refreshSession(refreshToken: String) async throws -> Session {
- refreshSessionCallCount += 1
- return try await refreshSessionHandler(refreshToken)
- }
+func withDependencies(_ mutation: (inout Dependencies) -> Void, operation: () async throws -> Void)
+ async rethrows
+{
+ let current = Dependencies.current.value ?? .mock
+ var copy = current
+ mutation(©)
+ Dependencies.current.withValue { $0 = copy }
+ defer { Dependencies.current.setValue(current) }
+ try await operation()
}
diff --git a/supabase-swift.xcworkspace/xcshareddata/swiftpm/Package.resolved b/supabase-swift.xcworkspace/xcshareddata/swiftpm/Package.resolved
index da8861b4..f36aa906 100644
--- a/supabase-swift.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/supabase-swift.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -9,6 +9,15 @@
"version" : "4.2.2"
}
},
+ {
+ "identity" : "svgview",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/exyte/SVGView",
+ "state" : {
+ "revision" : "6465962facdd25cb96eaebc35603afa2f15d2c0d",
+ "version" : "1.0.6"
+ }
+ },
{
"identity" : "swift-case-paths",
"kind" : "remoteSourceControl",