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",