From 6601f727d150934abd80917728a10f91bf557762 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 26 Oct 2023 09:23:51 -0300 Subject: [PATCH 01/11] Add interface for MFA methods --- Sources/GoTrue/GoTrueClient.swift | 4 ++ Sources/GoTrue/GoTrueMFA.swift | 78 +++++++++++++++++++++++++++++++ Sources/GoTrue/Types.swift | 49 +++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 Sources/GoTrue/GoTrueMFA.swift diff --git a/Sources/GoTrue/GoTrueClient.swift b/Sources/GoTrue/GoTrueClient.swift index 6782c12f..b9ba3bee 100644 --- a/Sources/GoTrue/GoTrueClient.swift +++ b/Sources/GoTrue/GoTrueClient.swift @@ -68,6 +68,9 @@ public actor GoTrueClient { } } + /// Namespace for accessing multi-factor authentication API. + public let mfa: GoTrueMFA + struct AuthChangeListener { var initialSessionTask: Task var continuation: AsyncStream.Continuation @@ -118,6 +121,7 @@ public actor GoTrueClient { self.api = APIClient(configuration: configuration, sessionManager: sessionManager) self.sessionManager = sessionManager self.codeVerifierStorage = codeVerifierStorage + self.mfa = GoTrueMFA() } deinit { diff --git a/Sources/GoTrue/GoTrueMFA.swift b/Sources/GoTrue/GoTrueMFA.swift new file mode 100644 index 00000000..69ad17fa --- /dev/null +++ b/Sources/GoTrue/GoTrueMFA.swift @@ -0,0 +1,78 @@ +// +// File.swift +// +// +// Created by Guilherme Souza on 26/10/23. +// + +import Foundation + +/// Contains the full multi-factor authentication API. +public actor GoTrueMFA { + /// 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 { + fatalError() + } + + /// 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 { + fatalError() + } + + /// 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 { + fatalError() + } + + /// 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. + public func unenroll(params: MFAUnenrollParams) async throws -> AuthMFAUnenrollResponse { + fatalError() + } + + /// 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. + public func challengeAndVerify(params: MFAChallengeAndVerifyParams) async throws + -> AuthMFAVerifyResponse + { + fatalError() + } + + /// 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 { + fatalError() + } + + /// 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 + { + fatalError() + } +} diff --git a/Sources/GoTrue/Types.swift b/Sources/GoTrue/Types.swift index 804645d1..1009b001 100644 --- a/Sources/GoTrue/Types.swift +++ b/Sources/GoTrue/Types.swift @@ -337,6 +337,55 @@ public enum AuthFlowType { case pkce } +public enum FactorType: String, Codable { + case totp +} + +public struct MFAEnrollParams: Encodable, Hashable { + 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?, friendlyName: String?) { + self.issuer = issuer + self.friendlyName = friendlyName + } +} + +public struct AuthMFAEnrollResponse: Decodable, Hashable { + /// 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 { + /// 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 let 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 loggin this value to the console. + public let url: String + } +} + +public struct MFAChallengeParams { /* Define parameters */ } +public struct MFAVerifyParams { /* Define parameters */ } +public struct MFAUnenrollParams { /* Define parameters */ } +public struct MFAChallengeAndVerifyParams { /* Define parameters */ } +public struct AuthMFAChallengeResponse { /* Define response */ } +public struct AuthMFAVerifyResponse { /* Define response */ } +public struct AuthMFAUnenrollResponse { /* Define response */ } +public struct AuthMFAListFactorsResponse { /* Define response */ } +public struct AuthMFAGetAuthenticatorAssuranceLevelResponse { /* Define response */ } + // MARK: - Encodable & Decodable private let dateFormatterWithFractionalSeconds = { () -> ISO8601DateFormatter in From 74a2e9b3690426784c0b5a394ef1ed7f7ad63e51 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 26 Oct 2023 18:25:17 -0300 Subject: [PATCH 02/11] Implement all MFA methods --- .../Data/AuthenticationRepository.swift | 3 +- Sources/GoTrue/GoTrueClient.swift | 134 ++++++++-------- Sources/GoTrue/GoTrueMFA.swift | 114 ++++++++++++-- Sources/GoTrue/Types.swift | 143 ++++++++++++++++-- Sources/_Helpers/AsyncStream.swift | 20 +-- 5 files changed, 320 insertions(+), 94 deletions(-) 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/Sources/GoTrue/GoTrueClient.swift b/Sources/GoTrue/GoTrueClient.swift index b9ba3bee..6a1ecd99 100644 --- a/Sources/GoTrue/GoTrueClient.swift +++ b/Sources/GoTrue/GoTrueClient.swift @@ -17,6 +17,57 @@ public struct AuthStateListenerHandle: Sendable { } } +actor EventEmitter { + // struct AuthChangeListener { + // var initialSessionTask: Task + // var continuation: AsyncStream.Continuation + // } + + 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) + } + } + + // let emitInitialSessionTask = Task { [id] in + // _debug("emitInitialSessionTask start") + // defer { _debug("emitInitialSessionTask end") } + // await emitInitialSession(forStreamWithID: id) + // } + + continuations[id] = continuation + + return (id, stream) + } + + private func removeStream(at id: UUID) { + self.continuations[id] = nil + } + + func emit(_ event: AuthChangeEvent, id: UUID? = nil) { + if let id { + continuations[id]?.yield(event) + } else { + for continuation in continuations.values { + continuation.yield(event) + } + } + } +} + public actor GoTrueClient { public typealias FetchHandler = @Sendable (_ request: URLRequest) async throws -> ( Data, @@ -60,6 +111,7 @@ public actor GoTrueClient { private let api: APIClient private let sessionManager: SessionManager private let codeVerifierStorage: CodeVerifierStorage + private let eventEmitter: EventEmitter /// Returns the session, refreshing it if necessary. public var session: Session { @@ -71,13 +123,6 @@ public actor GoTrueClient { /// Namespace for accessing multi-factor authentication API. public let mfa: GoTrueMFA - struct AuthChangeListener { - var initialSessionTask: Task - var continuation: AsyncStream.Continuation - } - - private(set) var authChangeListeners: [UUID: AuthChangeListener] = [:] - public init( url: URL, headers: [String: String] = [:], @@ -121,44 +166,30 @@ public actor GoTrueClient { self.api = APIClient(configuration: configuration, sessionManager: sessionManager) self.sessionManager = sessionManager self.codeVerifierStorage = codeVerifierStorage - self.mfa = GoTrueMFA() - } + self.eventEmitter = EventEmitter() - deinit { - authChangeListeners.forEach { - $0.value.continuation.finish() - } + self.mfa = GoTrueMFA( + api: api, sessionManager: sessionManager, configuration: configuration, + eventEmitter: eventEmitter) } - 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") defer { _debug("emitInitialSessionTask end") } await emitInitialSession(forStreamWithID: id) } - - authChangeListeners[id] = AuthChangeListener( - initialSessionTask: emitInitialSessionTask, - continuation: continuation - ) + // + // 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. @@ -251,7 +282,7 @@ public actor GoTrueClient { if let session = response.session { try await sessionManager.update(session) - await emitAuthChangeEvent(.signedIn) + await eventEmitter.emit(.signedIn) } return response @@ -311,7 +342,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 @@ -414,7 +445,7 @@ public actor GoTrueClient { try codeVerifierStorage.deleteCodeVerifier() try await sessionManager.update(session) - await emitAuthChangeEvent(.signedIn) + await eventEmitter.emit(.signedIn) return session } catch { @@ -517,10 +548,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 @@ -569,7 +600,7 @@ public actor GoTrueClient { } try await sessionManager.update(session) - await emitAuthChangeEvent(.tokenRefreshed) + await eventEmitter.emit(.tokenRefreshed) return session } @@ -584,9 +615,9 @@ public actor GoTrueClient { ) ) await sessionManager.remove() - await emitAuthChangeEvent(.signedOut) + await eventEmitter.emit(.signedOut) } catch { - await emitAuthChangeEvent(.signedOut) + await eventEmitter.emit(.signedOut) throw error } } @@ -653,7 +684,7 @@ public actor GoTrueClient { if let session = response.session { try await sessionManager.update(session) - await emitAuthChangeEvent(.signedIn) + await eventEmitter.emit(.signedIn) } return response @@ -676,7 +707,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 } @@ -703,27 +734,12 @@ public actor GoTrueClient { ) } - private func emitAuthChangeEvent(_ event: AuthChangeEvent) async { - _debug("start") - defer { _debug("end") } - - let listeners = authChangeListeners.values - for listener in listeners { - listener.continuation.yield(event) - } - } - 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( @@ -766,7 +782,7 @@ extension GoTrueClient: SessionRefresher { .user.confirmedAt != nil { try await sessionManager.update(session) - await emitAuthChangeEvent(.signedIn) + await eventEmitter.emit(.signedIn) } return session diff --git a/Sources/GoTrue/GoTrueMFA.swift b/Sources/GoTrue/GoTrueMFA.swift index 69ad17fa..986e53ba 100644 --- a/Sources/GoTrue/GoTrueMFA.swift +++ b/Sources/GoTrue/GoTrueMFA.swift @@ -6,13 +6,29 @@ // import Foundation +@_spi(Internal) import _Helpers /// Contains the full multi-factor authentication API. public actor GoTrueMFA { - /// 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. + let api: APIClient + let sessionManager: SessionManager + let configuration: GoTrueClient.Configuration + let eventEmitter: EventEmitter + + init( + api: APIClient, + sessionManager: SessionManager, + configuration: GoTrueClient.Configuration, + eventEmitter: EventEmitter + ) { + self.api = api + self.sessionManager = sessionManager + self.configuration = configuration + self.eventEmitter = 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`. @@ -20,7 +36,20 @@ public actor GoTrueMFA { /// - 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 { - fatalError() + let response: AuthMFAEnrollResponse = try await api.authorizedExecute( + Request( + path: "/factors", method: "POST", + body: configuration.encoder.encode(params) + ) + ) + .decoded(decoder: configuration.decoder) + + // TODO: check if we really need this. + // if let qrCode = response.totp?.qrCode { + // response.totp?.qrCode = "data:image/svg+xml;utf-8,\(qrCode)" + // } + + return response } /// Prepares a challenge used to verify that a user has access to a MFA factor. @@ -28,7 +57,10 @@ public actor GoTrueMFA { /// - Parameter params: The parameters for creating a challenge. /// - Returns: An authentication response with the challenge information. public func challenge(params: MFAChallengeParams) async throws -> AuthMFAChallengeResponse { - fatalError() + 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 @@ -37,7 +69,18 @@ public actor GoTrueMFA { /// - 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 { - fatalError() + 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. @@ -46,7 +89,10 @@ public actor GoTrueMFA { /// - Parameter params: The parameters for unenrolling an MFA factor. /// - Returns: An authentication response after unenrolling the factor. public func unenroll(params: MFAUnenrollParams) async throws -> AuthMFAUnenrollResponse { - fatalError() + 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 @@ -57,14 +103,22 @@ public actor GoTrueMFA { public func challengeAndVerify(params: MFAChallengeAndVerifyParams) async throws -> AuthMFAVerifyResponse { - fatalError() + 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 { - fatalError() + 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. @@ -73,6 +127,44 @@ public actor GoTrueMFA { public func getAuthenticatorAssuranceLevel() async throws -> AuthMFAGetAuthenticatorAssuranceLevelResponse { - fatalError() + do { + let session = try await sessionManager.session() + let payload = try decode(jwt: session.accessToken) + + var currentLevel: AuthenticatorAssuranceLevels? + + if let aal = payload["aal"].flatMap({ $0 as? String }).flatMap( + AuthenticatorAssuranceLevels.init) + { + 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/Types.swift b/Sources/GoTrue/Types.swift index 1009b001..5535ba15 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 { @@ -97,6 +98,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 +120,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 +143,7 @@ public struct User: Codable, Hashable, Identifiable, Sendable { self.role = role self.updatedAt = updatedAt self.identities = identities + self.factors = factors } } @@ -337,10 +341,33 @@ public enum AuthFlowType { case pkce } -public enum FactorType: String, Codable { +public enum FactorType: String, Codable, Sendable { case totp } +public enum FactorStatus: String, Codable, Sendable { + case verified + case unverified +} + +/// An MFA Factor. +public struct Factor: Codable, Hashable, Sendable { + /// ID of the factor. + public let id: String + + /// Friendly name of the factor, useful to disambiguate between multiple factors. + public let friendlyMame: String? + + /// Type of factor. Only `totp` supported with this version but may change in future versions. + public let factorType: FactorType + + /// Factor's status. + public let status: FactorStatus + + public let createdAt: Date + public let updatedAt: Date +} + public struct MFAEnrollParams: Encodable, Hashable { public let factorType: FactorType = .totp /// Domain which the user is enrolled with. @@ -362,11 +389,11 @@ public struct AuthMFAEnrollResponse: Decodable, Hashable { public let type: FactorType /// TOTP enrollment information. - public var totp: TOTP + public var totp: TOTP? public struct TOTP: Decodable, Hashable { /// 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 let qrCode: String + 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 @@ -376,15 +403,105 @@ public struct AuthMFAEnrollResponse: Decodable, Hashable { } } -public struct MFAChallengeParams { /* Define parameters */ } -public struct MFAVerifyParams { /* Define parameters */ } -public struct MFAUnenrollParams { /* Define parameters */ } -public struct MFAChallengeAndVerifyParams { /* Define parameters */ } -public struct AuthMFAChallengeResponse { /* Define response */ } -public struct AuthMFAVerifyResponse { /* Define response */ } -public struct AuthMFAUnenrollResponse { /* Define response */ } -public struct AuthMFAListFactorsResponse { /* Define response */ } -public struct AuthMFAGetAuthenticatorAssuranceLevelResponse { /* Define response */ } +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 { + /// ID of the factor to unenroll. Returned in ``GoTrueMFA.enroll(params:)``. + public let factorId: String +} + +public struct MFAChallengeAndVerifyParams: Encodable, Hashable { + /// 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 struct AuthMFAChallengeResponse: Decodable, Hashable { + /// 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 { + /// ID of the factor that was successfully unenrolled. + public let factorId: String +} + +public struct AuthMFAListFactorsResponse: Decodable, Hashable { + /// All available factors (verified and unverified). + public let all: [Factor] + + /// Only verified TOTP factors. (A subset of `all`.) + public let totp: [Factor] +} + +public enum AuthenticatorAssuranceLevels: String, Codable { + case aal1 + case aal2 +} + +/// 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 { + /// Authentication method name. + public let method: Method + + /// Timestamp when the method was successfully used. + public let timestamp: TimeInterval + + public enum Method: String, Decodable { + case password + case otp + case oauth + case mfaTOTP = "mfa/totp" + } +} + +extension AMREntry { + init?(value: Any) { + guard let dict = value as? [String: Any], + let method = dict["method"].flatMap({ $0 as? String }).flatMap(Method.init), + let timestamp = dict["timestamp"].flatMap({ $0 as? TimeInterval }) + else { + return nil + } + + self.method = method + self.timestamp = timestamp + } +} + +public struct AuthMFAGetAuthenticatorAssuranceLevelResponse: Decodable, Hashable { + /// 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 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 } From 0ea914f39da58376d9ab93370118b498217d55ec Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 26 Oct 2023 18:29:53 -0300 Subject: [PATCH 03/11] Move EventEmitter --- Sources/GoTrue/GoTrueClient.swift | 53 +--------------------- Sources/GoTrue/Internal/EventEmitter.swift | 53 ++++++++++++++++++++++ 2 files changed, 54 insertions(+), 52 deletions(-) create mode 100644 Sources/GoTrue/Internal/EventEmitter.swift diff --git a/Sources/GoTrue/GoTrueClient.swift b/Sources/GoTrue/GoTrueClient.swift index 6a1ecd99..6e4c0b22 100644 --- a/Sources/GoTrue/GoTrueClient.swift +++ b/Sources/GoTrue/GoTrueClient.swift @@ -17,57 +17,6 @@ public struct AuthStateListenerHandle: Sendable { } } -actor EventEmitter { - // struct AuthChangeListener { - // var initialSessionTask: Task - // var continuation: AsyncStream.Continuation - // } - - 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) - } - } - - // let emitInitialSessionTask = Task { [id] in - // _debug("emitInitialSessionTask start") - // defer { _debug("emitInitialSessionTask end") } - // await emitInitialSession(forStreamWithID: id) - // } - - continuations[id] = continuation - - return (id, stream) - } - - private func removeStream(at id: UUID) { - self.continuations[id] = nil - } - - func emit(_ event: AuthChangeEvent, id: UUID? = nil) { - if let id { - continuations[id]?.yield(event) - } else { - for continuation in continuations.values { - continuation.yield(event) - } - } - } -} - public actor GoTrueClient { public typealias FetchHandler = @Sendable (_ request: URLRequest) async throws -> ( Data, @@ -166,7 +115,7 @@ public actor GoTrueClient { self.api = APIClient(configuration: configuration, sessionManager: sessionManager) self.sessionManager = sessionManager self.codeVerifierStorage = codeVerifierStorage - self.eventEmitter = EventEmitter() + self.eventEmitter = DefaultEventEmitter() self.mfa = GoTrueMFA( api: api, sessionManager: sessionManager, configuration: configuration, 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 + } +} From 3e390f78a0d19869c120b2fd8d6ca56ff5541fbe Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 26 Oct 2023 18:36:07 -0300 Subject: [PATCH 04/11] Code cleanup --- Sources/GoTrue/GoTrueClient.swift | 63 ++++++++++++++++++------------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/Sources/GoTrue/GoTrueClient.swift b/Sources/GoTrue/GoTrueClient.swift index 6e4c0b22..dc1f3ba5 100644 --- a/Sources/GoTrue/GoTrueClient.swift +++ b/Sources/GoTrue/GoTrueClient.swift @@ -7,21 +7,9 @@ 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 let url: URL @@ -95,31 +83,50 @@ public actor GoTrueClient { } public init(configuration: Configuration) { + var configuration = configuration + configuration.headers["X-Client-Info"] = "gotrue-swift/\(version)" + + let sessionManager = DefaultSessionManager( + storage: DefaultSessionStorage( + localStorage: configuration.localStorage + ) + ) + let codeVerifierStorage = DefaultCodeVerifierStorage(localStorage: configuration.localStorage) + let api = APIClient(configuration: configuration, sessionManager: sessionManager) + let eventEmitter = DefaultEventEmitter() + + let mfa = GoTrueMFA( + api: api, + sessionManager: sessionManager, + configuration: configuration, + eventEmitter: eventEmitter + ) + self.init( configuration: configuration, - sessionManager: DefaultSessionManager( - storage: DefaultSessionStorage(localStorage: configuration.localStorage) - ), - codeVerifierStorage: DefaultCodeVerifierStorage(localStorage: configuration.localStorage) + sessionManager: sessionManager, + codeVerifierStorage: codeVerifierStorage, + api: api, + eventEmitter: eventEmitter, + mfa: mfa ) } + /// 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, + mfa: GoTrueMFA ) { - var configuration = configuration - configuration.headers["X-Client-Info"] = "gotrue-swift/\(version)" self.configuration = configuration - self.api = APIClient(configuration: configuration, sessionManager: sessionManager) + self.api = api self.sessionManager = sessionManager self.codeVerifierStorage = codeVerifierStorage - self.eventEmitter = DefaultEventEmitter() - - self.mfa = GoTrueMFA( - api: api, sessionManager: sessionManager, configuration: configuration, - eventEmitter: eventEmitter) + self.eventEmitter = eventEmitter + self.mfa = mfa } public func onAuthStateChange() async -> AsyncStream { @@ -130,6 +137,8 @@ public actor GoTrueClient { defer { _debug("emitInitialSessionTask end") } await emitInitialSession(forStreamWithID: id) } + + // TODO: store the emitInitialSessionTask somewhere, and cancel it when AsyncStream finishes. // // authChangeListeners[id] = AuthChangeListener( // initialSessionTask: emitInitialSessionTask, From cd0cf1edcbb079fd89ea5e602a5889581436c61a Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 26 Oct 2023 19:16:30 -0300 Subject: [PATCH 05/11] Do not use enums on some response models --- Sources/GoTrue/GoTrueClient.swift | 14 +++--- Sources/GoTrue/GoTrueMFA.swift | 8 ++-- Sources/GoTrue/Types.swift | 24 +++------- Sources/_Helpers/Task.swift | 2 +- Tests/GoTrueTests/GoTrueClientTests.swift | 57 +++++++++++++++-------- Tests/GoTrueTests/RequestsTests.swift | 55 ++++++++++++++-------- 6 files changed, 92 insertions(+), 68 deletions(-) diff --git a/Sources/GoTrue/GoTrueClient.swift b/Sources/GoTrue/GoTrueClient.swift index dc1f3ba5..49c5d226 100644 --- a/Sources/GoTrue/GoTrueClient.swift +++ b/Sources/GoTrue/GoTrueClient.swift @@ -8,7 +8,7 @@ public typealias AnyJSON = _Helpers.AnyJSON #endif public actor GoTrueClient { - public typealias FetchHandler = + public typealias FetchHandler = @Sendable (_ request: URLRequest) async throws -> (Data, URLResponse) public struct Configuration { @@ -29,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 @@ -48,7 +53,7 @@ public actor GoTrueClient { private let api: APIClient private let sessionManager: SessionManager private let codeVerifierStorage: CodeVerifierStorage - private let eventEmitter: EventEmitter + let eventEmitter: EventEmitter /// Returns the session, refreshing it if necessary. public var session: Session { @@ -83,9 +88,6 @@ public actor GoTrueClient { } public init(configuration: Configuration) { - var configuration = configuration - configuration.headers["X-Client-Info"] = "gotrue-swift/\(version)" - let sessionManager = DefaultSessionManager( storage: DefaultSessionStorage( localStorage: configuration.localStorage @@ -96,7 +98,7 @@ public actor GoTrueClient { let eventEmitter = DefaultEventEmitter() let mfa = GoTrueMFA( - api: api, + api: api, sessionManager: sessionManager, configuration: configuration, eventEmitter: eventEmitter diff --git a/Sources/GoTrue/GoTrueMFA.swift b/Sources/GoTrue/GoTrueMFA.swift index 986e53ba..7b63c06c 100644 --- a/Sources/GoTrue/GoTrueMFA.swift +++ b/Sources/GoTrue/GoTrueMFA.swift @@ -116,7 +116,7 @@ public actor GoTrueMFA { let user = try await sessionManager.session().user let factors = user.factors ?? [] let totp = factors.filter { - $0.factorType == .totp && $0.status == .verified + $0.factorType == "totp" && $0.status == .verified } return AuthMFAListFactorsResponse(all: factors, totp: totp) } @@ -133,9 +133,7 @@ public actor GoTrueMFA { var currentLevel: AuthenticatorAssuranceLevels? - if let aal = payload["aal"].flatMap({ $0 as? String }).flatMap( - AuthenticatorAssuranceLevels.init) - { + if let aal = payload["aal"] as? AuthenticatorAssuranceLevels { currentLevel = aal } @@ -143,7 +141,7 @@ public actor GoTrueMFA { let verifiedFactors = session.user.factors?.filter({ $0.status == .verified }) ?? [] if !verifiedFactors.isEmpty { - nextLevel = .aal2 + nextLevel = "aal2" } var currentAuthenticationMethods: [AMREntry] = [] diff --git a/Sources/GoTrue/Types.swift b/Sources/GoTrue/Types.swift index 5535ba15..1cf29578 100644 --- a/Sources/GoTrue/Types.swift +++ b/Sources/GoTrue/Types.swift @@ -341,9 +341,7 @@ public enum AuthFlowType { case pkce } -public enum FactorType: String, Codable, Sendable { - case totp -} +public typealias FactorType = String public enum FactorStatus: String, Codable, Sendable { case verified @@ -359,7 +357,7 @@ public struct Factor: Codable, Hashable, Sendable { public let friendlyMame: String? /// Type of factor. Only `totp` supported with this version but may change in future versions. - public let factorType: FactorType + public let factorType: String /// Factor's status. public let status: FactorStatus @@ -369,7 +367,7 @@ public struct Factor: Codable, Hashable, Sendable { } public struct MFAEnrollParams: Encodable, Hashable { - public let factorType: FactorType = .totp + public let factorType: FactorType = "totp" /// Domain which the user is enrolled with. public let issuer: String? /// Human readable name assigned to the factor. @@ -455,10 +453,7 @@ public struct AuthMFAListFactorsResponse: Decodable, Hashable { public let totp: [Factor] } -public enum AuthenticatorAssuranceLevels: String, Codable { - case aal1 - case aal2 -} +public typealias AuthenticatorAssuranceLevels = String /// An authentication method reference (AMR) entry. /// @@ -470,19 +465,14 @@ public struct AMREntry: Decodable, Hashable { /// Timestamp when the method was successfully used. public let timestamp: TimeInterval - public enum Method: String, Decodable { - case password - case otp - case oauth - case mfaTOTP = "mfa/totp" - } + public typealias Method = String } extension AMREntry { init?(value: Any) { guard let dict = value as? [String: Any], - let method = dict["method"].flatMap({ $0 as? String }).flatMap(Method.init), - let timestamp = dict["timestamp"].flatMap({ $0 as? TimeInterval }) + let method = dict["method"] as? Method, + let timestamp = dict["timestamp"] as? TimeInterval else { return nil } 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..364bf5c9 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,44 @@ 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(configuration: configuration, sessionManager: sessionManager) + + let sut = GoTrueClient( + configuration: configuration, sessionManager: sessionManager, - codeVerifierStorage: codeVerifierStorage + codeVerifierStorage: codeVerifierStorage, + api: api, + eventEmitter: eventEmitter, + mfa: GoTrueMFA( + api: api, + sessionManager: sessionManager, + configuration: configuration, + eventEmitter: eventEmitter + ) ) addTeardownBlock { [weak sut] in @@ -112,3 +120,12 @@ final class CodeVerifierStorageMock: CodeVerifierStorage { codeVerifier = nil } } +// +//final class EventEmitterMock: EventEmitter { +// func attachListener() async -> (id: UUID, stream: AsyncStream) { +// return (UUID(), AsyncStream.makeStream().stream) +// } +// +// func emit(_ event: AuthChangeEvent, id: UUID?) async { +// } +//} diff --git a/Tests/GoTrueTests/RequestsTests.swift b/Tests/GoTrueTests/RequestsTests.swift index 20fb5e1f..3cde924a 100644 --- a/Tests/GoTrueTests/RequestsTests.swift +++ b/Tests/GoTrueTests/RequestsTests.swift @@ -269,26 +269,43 @@ final class RequestsTests: XCTestCase { 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(storage: storage) + + 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) + } + + if let fetch { + return try await fetch(request) } - ), - sessionManager: DefaultSessionManager(storage: storage), - codeVerifierStorage: CodeVerifierStorageMock() + + throw UnimplementedError() + } + ) + + let eventEmitter = DefaultEventEmitter() + + // TODO: Inject a mocked APIClient + let api = APIClient(configuration: configuration, sessionManager: sessionManager) + + // TODO: Inject a mocked GoTrueMFA + let mfa = GoTrueMFA( + api: api, sessionManager: sessionManager, configuration: configuration, + eventEmitter: eventEmitter) + + return GoTrueClient( + configuration: configuration, + sessionManager: sessionManager, + codeVerifierStorage: CodeVerifierStorageMock(), + api: api, + eventEmitter: eventEmitter, + mfa: mfa ) } } From d15ad26b6fc30b253dc36aab084d5e92fc52d023 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 27 Oct 2023 06:57:12 -0300 Subject: [PATCH 06/11] Adding MFA Flow to Example project --- .../xcschemes/Supabase-Package.xcscheme | 56 +++++++++++++++++++ Examples/Examples.xcodeproj/project.pbxproj | 12 ++++ Examples/Examples/AuthView.swift | 14 ++++- Examples/Examples/HomeView.swift | 30 ++++++++++ Examples/Examples/Info.plist | 17 ++++++ Examples/Examples/MFAFlow.swift | 36 ++++++++++++ Examples/Examples/RootView.swift | 20 +++---- Sources/GoTrue/Types.swift | 6 +- 8 files changed, 174 insertions(+), 17 deletions(-) create mode 100644 Examples/Examples/Info.plist create mode 100644 Examples/Examples/MFAFlow.swift 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..282690a6 --- /dev/null +++ b/Examples/Examples/MFAFlow.swift @@ -0,0 +1,36 @@ +// +// MFAFlow.swift +// Examples +// +// Created by Guilherme Souza on 27/10/23. +// + +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 { + Text(status.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/Sources/GoTrue/Types.swift b/Sources/GoTrue/Types.swift index 1cf29578..5026e2d1 100644 --- a/Sources/GoTrue/Types.swift +++ b/Sources/GoTrue/Types.swift @@ -373,7 +373,7 @@ public struct MFAEnrollParams: Encodable, Hashable { /// Human readable name assigned to the factor. public let friendlyName: String? - public init(issuer: String?, friendlyName: String?) { + public init(issuer: String? = nil, friendlyName: String? = nil) { self.issuer = issuer self.friendlyName = friendlyName } @@ -458,7 +458,7 @@ 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 { +public struct AMREntry: Decodable, Hashable, Sendable { /// Authentication method name. public let method: Method @@ -482,7 +482,7 @@ extension AMREntry { } } -public struct AuthMFAGetAuthenticatorAssuranceLevelResponse: Decodable, Hashable { +public struct AuthMFAGetAuthenticatorAssuranceLevelResponse: Decodable, Hashable, Sendable { /// Current AAL level of the session. public let currentLevel: AuthenticatorAssuranceLevels? From fb99a291acd9c65288f3be25293403a4d6133ceb Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 27 Oct 2023 08:09:07 -0300 Subject: [PATCH 07/11] Add MFA Enrollment view --- Examples/Examples.xcodeproj/project.pbxproj | 17 +++ Examples/Examples/MFAFlow.swift | 124 +++++++++++++++++- Sources/GoTrue/GoTrueMFA.swift | 2 + Sources/GoTrue/Types.swift | 33 +++-- .../xcshareddata/swiftpm/Package.resolved | 9 ++ 5 files changed, 172 insertions(+), 13 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 0e7681b4..ab6a3112 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -30,6 +30,7 @@ 7956406D2955B3500088A06F /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = 7956406C2955B3500088A06F /* SwiftUINavigation */; }; 795640702955B5190088A06F /* IdentifiedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 7956406F2955B5190088A06F /* IdentifiedCollections */; }; 796298992AEBBA77000AA957 /* MFAFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 796298982AEBBA77000AA957 /* MFAFlow.swift */; }; + 7962989D2AEBC6F9000AA957 /* SVGView in Frameworks */ = {isa = PBXBuildFile; productRef = 7962989C2AEBC6F9000AA957 /* SVGView */; }; 79719ECE2ADF26C400737804 /* Supabase in Frameworks */ = {isa = PBXBuildFile; productRef = 79719ECD2ADF26C400737804 /* Supabase */; }; 79C591DC2AE0880F0088A9C8 /* ProductSampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C591DB2AE0880F0088A9C8 /* ProductSampleApp.swift */; }; 79C591DE2AE0880F0088A9C8 /* AppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79C591DD2AE0880F0088A9C8 /* AppView.swift */; }; @@ -112,6 +113,7 @@ files = ( 795640702955B5190088A06F /* IdentifiedCollections in Frameworks */, 7956406D2955B3500088A06F /* SwiftUINavigation in Frameworks */, + 7962989D2AEBC6F9000AA957 /* SVGView in Frameworks */, 79719ECE2ADF26C400737804 /* Supabase in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -344,6 +346,7 @@ 7956406C2955B3500088A06F /* SwiftUINavigation */, 7956406F2955B5190088A06F /* IdentifiedCollections */, 79719ECD2ADF26C400737804 /* Supabase */, + 7962989C2AEBC6F9000AA957 /* SVGView */, ); productName = Examples; productReference = 793895C62954ABFF0044F2B8 /* Examples.app */; @@ -399,6 +402,7 @@ packageReferences = ( 7956406B2955B3500088A06F /* XCRemoteSwiftPackageReference "swiftui-navigation" */, 7956406E2955B5190088A06F /* XCRemoteSwiftPackageReference "swift-identified-collections" */, + 7962989B2AEBC6F9000AA957 /* XCRemoteSwiftPackageReference "SVGView" */, ); productRefGroup = 793895C72954ABFF0044F2B8 /* Products */; projectDirPath = ""; @@ -755,6 +759,14 @@ minimumVersion = 1.0.0; }; }; + 7962989B2AEBC6F9000AA957 /* XCRemoteSwiftPackageReference "SVGView" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/exyte/SVGView"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.6; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -768,6 +780,11 @@ package = 7956406E2955B5190088A06F /* XCRemoteSwiftPackageReference "swift-identified-collections" */; productName = IdentifiedCollections; }; + 7962989C2AEBC6F9000AA957 /* SVGView */ = { + isa = XCSwiftPackageProductDependency; + package = 7962989B2AEBC6F9000AA957 /* XCRemoteSwiftPackageReference "SVGView" */; + productName = SVGView; + }; 79719ECD2ADF26C400737804 /* Supabase */ = { isa = XCSwiftPackageProductDependency; productName = Supabase; diff --git a/Examples/Examples/MFAFlow.swift b/Examples/Examples/MFAFlow.swift index 282690a6..864c56e7 100644 --- a/Examples/Examples/MFAFlow.swift +++ b/Examples/Examples/MFAFlow.swift @@ -5,6 +5,8 @@ // Created by Guilherme Souza on 27/10/23. // +import SVGView +import Supabase import SwiftUI enum MFAStatus { @@ -31,6 +33,126 @@ struct MFAFlow: View { let status: MFAStatus var body: some View { - Text(status.description) + 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 + } + } + } + + 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 { + var body: some View { + Text("Verify") + } +} + +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("Disabled") } } diff --git a/Sources/GoTrue/GoTrueMFA.swift b/Sources/GoTrue/GoTrueMFA.swift index 7b63c06c..4d576053 100644 --- a/Sources/GoTrue/GoTrueMFA.swift +++ b/Sources/GoTrue/GoTrueMFA.swift @@ -88,6 +88,7 @@ public actor GoTrueMFA { /// /// - 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") @@ -100,6 +101,7 @@ public actor GoTrueMFA { /// /// - 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 { diff --git a/Sources/GoTrue/Types.swift b/Sources/GoTrue/Types.swift index 5026e2d1..81d96182 100644 --- a/Sources/GoTrue/Types.swift +++ b/Sources/GoTrue/Types.swift @@ -349,12 +349,12 @@ public enum FactorStatus: String, Codable, Sendable { } /// An MFA Factor. -public struct Factor: Codable, Hashable, Sendable { +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 friendlyMame: String? + public let friendlyName: String? /// Type of factor. Only `totp` supported with this version but may change in future versions. public let factorType: String @@ -366,7 +366,7 @@ public struct Factor: Codable, Hashable, Sendable { public let updatedAt: Date } -public struct MFAEnrollParams: Encodable, Hashable { +public struct MFAEnrollParams: Encodable, Hashable, Sendable { public let factorType: FactorType = "totp" /// Domain which the user is enrolled with. public let issuer: String? @@ -379,7 +379,7 @@ public struct MFAEnrollParams: Encodable, Hashable { } } -public struct AuthMFAEnrollResponse: Decodable, Hashable { +public struct AuthMFAEnrollResponse: Decodable, Hashable, Sendable { /// ID of the factor that was just enrolled (in an unverified state). public let id: String @@ -389,15 +389,15 @@ public struct AuthMFAEnrollResponse: Decodable, Hashable { /// TOTP enrollment information. public var totp: TOTP? - public struct TOTP: Decodable, Hashable { + 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 loggin this value to the console. - public let url: 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 } } @@ -417,20 +417,29 @@ public struct MFAVerifyParams: Encodable, Hashable { public let code: String } -public struct MFAUnenrollParams: Encodable, Hashable { +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 { +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 { +public struct AuthMFAChallengeResponse: Decodable, Hashable, Sendable { /// ID of the newly created challenge. public let id: String @@ -440,12 +449,12 @@ public struct AuthMFAChallengeResponse: Decodable, Hashable { public typealias AuthMFAVerifyResponse = Session -public struct AuthMFAUnenrollResponse: Decodable, Hashable { +public struct AuthMFAUnenrollResponse: Decodable, Hashable, Sendable { /// ID of the factor that was successfully unenrolled. public let factorId: String } -public struct AuthMFAListFactorsResponse: Decodable, Hashable { +public struct AuthMFAListFactorsResponse: Decodable, Hashable, Sendable { /// All available factors (verified and unverified). public let all: [Factor] 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", From f88f7664b4f44c23749c216f1857b366a86e505f Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 27 Oct 2023 08:30:33 -0300 Subject: [PATCH 08/11] Use Dependency Container --- Sources/GoTrue/GoTrueClient.swift | 60 +++++++++++-------- Sources/GoTrue/GoTrueMFA.swift | 45 +++++--------- Sources/GoTrue/Internal/APIClient.swift | 11 ++-- .../GoTrue/Internal/CodeVerifierStorage.swift | 13 ++-- Sources/GoTrue/Internal/Dependencies.swift | 13 ++++ Sources/GoTrue/Internal/SessionManager.swift | 23 +++---- Sources/GoTrue/Internal/SessionStorage.swift | 5 +- 7 files changed, 85 insertions(+), 85 deletions(-) create mode 100644 Sources/GoTrue/Internal/Dependencies.swift diff --git a/Sources/GoTrue/GoTrueClient.swift b/Sources/GoTrue/GoTrueClient.swift index 49c5d226..43813b9d 100644 --- a/Sources/GoTrue/GoTrueClient.swift +++ b/Sources/GoTrue/GoTrueClient.swift @@ -49,11 +49,23 @@ 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 - let eventEmitter: EventEmitter + + private var eventEmitter: EventEmitter { + Dependencies.current.value!.eventEmitter + } /// Returns the session, refreshing it if necessary. public var session: Session { @@ -88,21 +100,12 @@ public actor GoTrueClient { } public init(configuration: Configuration) { - let sessionManager = DefaultSessionManager( - storage: DefaultSessionStorage( - localStorage: configuration.localStorage - ) - ) - let codeVerifierStorage = DefaultCodeVerifierStorage(localStorage: configuration.localStorage) - let api = APIClient(configuration: configuration, sessionManager: sessionManager) - let eventEmitter = DefaultEventEmitter() + let sessionStorage = DefaultSessionStorage() + let sessionManager = DefaultSessionManager() - let mfa = GoTrueMFA( - api: api, - sessionManager: sessionManager, - configuration: configuration, - eventEmitter: eventEmitter - ) + let codeVerifierStorage = DefaultCodeVerifierStorage() + let api = APIClient() + let eventEmitter = DefaultEventEmitter() self.init( configuration: configuration, @@ -110,7 +113,7 @@ public actor GoTrueClient { codeVerifierStorage: codeVerifierStorage, api: api, eventEmitter: eventEmitter, - mfa: mfa + sessionStorage: sessionStorage ) } @@ -121,14 +124,21 @@ public actor GoTrueClient { codeVerifierStorage: CodeVerifierStorage, api: APIClient, eventEmitter: EventEmitter, - mfa: GoTrueMFA + sessionStorage: SessionStorage ) { - self.configuration = configuration - self.api = api - self.sessionManager = sessionManager self.codeVerifierStorage = codeVerifierStorage - self.eventEmitter = eventEmitter - self.mfa = mfa + self.mfa = GoTrueMFA() + + Dependencies.current.setValue( + Dependencies( + configuration: configuration, + sessionManager: sessionManager, + api: api, + eventEmitter: eventEmitter, + sessionStorage: sessionStorage, + sessionRefresher: self + ) + ) } public func onAuthStateChange() async -> AsyncStream { diff --git a/Sources/GoTrue/GoTrueMFA.swift b/Sources/GoTrue/GoTrueMFA.swift index 4d576053..fe3b2041 100644 --- a/Sources/GoTrue/GoTrueMFA.swift +++ b/Sources/GoTrue/GoTrueMFA.swift @@ -1,30 +1,22 @@ -// -// File.swift -// -// -// Created by Guilherme Souza on 26/10/23. -// - import Foundation @_spi(Internal) import _Helpers /// Contains the full multi-factor authentication API. public actor GoTrueMFA { - let api: APIClient - let sessionManager: SessionManager - let configuration: GoTrueClient.Configuration - let eventEmitter: EventEmitter - - init( - api: APIClient, - sessionManager: SessionManager, - configuration: GoTrueClient.Configuration, - eventEmitter: EventEmitter - ) { - self.api = api - self.sessionManager = sessionManager - self.configuration = configuration - self.eventEmitter = eventEmitter + 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. @@ -36,20 +28,13 @@ public actor GoTrueMFA { /// - 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 { - let response: AuthMFAEnrollResponse = try await api.authorizedExecute( + try await api.authorizedExecute( Request( path: "/factors", method: "POST", body: configuration.encoder.encode(params) ) ) .decoded(decoder: configuration.decoder) - - // TODO: check if we really need this. - // if let qrCode = response.totp?.qrCode { - // response.totp?.qrCode = "data:image/svg+xml;utf-8,\(qrCode)" - // } - - return response } /// Prepares a challenge used to verify that a user has access to a MFA factor. 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..484a43dd --- /dev/null +++ b/Sources/GoTrue/Internal/Dependencies.swift @@ -0,0 +1,13 @@ +import Foundation +@_spi(Internal) import _Helpers + +struct Dependencies { + 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/SessionManager.swift b/Sources/GoTrue/Internal/SessionManager.swift index 0bdcf155..0d8e8913 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 } 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,10 @@ actor DefaultSessionManager: SessionManager { task = Task { defer { task = nil } - if let session = try await sessionRefresher?.refreshSession( + let session = try await sessionRefresher.refreshSession( refreshToken: currentSession.session.refreshToken) - { - try update(session) - return session - } - - throw GoTrueError.sessionNotFound + try update(session) + return session } return try await task!.value diff --git a/Sources/GoTrue/Internal/SessionStorage.swift b/Sources/GoTrue/Internal/SessionStorage.swift index 3b9e4d8a..2a749899 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 { @@ -29,7 +30,9 @@ protocol SessionStorage { } struct DefaultSessionStorage: SessionStorage { - let localStorage: GoTrueLocalStorage + private var localStorage: GoTrueLocalStorage { + Dependencies.current.value!.configuration.localStorage + } func getSession() throws -> StoredSession? { try localStorage.retrieve(key: "supabase.session").flatMap { From 38a8d51eb811b54f4b7cdb2612a9d96554eef0e2 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 27 Oct 2023 08:46:36 -0300 Subject: [PATCH 09/11] Add MFA verify flow --- Examples/Examples/MFAFlow.swift | 53 +++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/Examples/Examples/MFAFlow.swift b/Examples/Examples/MFAFlow.swift index 864c56e7..c89d88b0 100644 --- a/Examples/Examples/MFAFlow.swift +++ b/Examples/Examples/MFAFlow.swift @@ -99,6 +99,7 @@ struct MFAEnrollView: View { } } + @MainActor private func enableButtonTapped() { Task { do { @@ -112,8 +113,56 @@ struct MFAEnrollView: View { } struct MFAVerifyView: View { + @Environment(\.dismiss) private var dismiss + @State private var verificationCode = "" + @State private var error: Error? + var body: some View { - Text("Verify") + 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 + } + } } } @@ -153,6 +202,6 @@ struct MFAVerifiedView: View { struct MFADisabledView: View { var body: some View { - Text("Disabled") + Text(MFAStatus.disabled.description) } } From d2423543dd1560ce8190d9d3dcdc4244d01ada3d Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 27 Oct 2023 09:43:47 -0300 Subject: [PATCH 10/11] Fix tests --- Package.resolved | 9 ++ Package.swift | 2 + Sources/GoTrue/GoTrueClient.swift | 61 ++++---- Sources/GoTrue/Internal/Dependencies.swift | 2 +- Sources/GoTrue/Internal/SessionManager.swift | 9 +- Sources/GoTrue/Internal/SessionStorage.swift | 42 +++--- Sources/GoTrue/Types.swift | 17 ++- Tests/GoTrueTests/GoTrueClientTests.swift | 11 +- Tests/GoTrueTests/RequestsTests.swift | 101 +++++++------ Tests/GoTrueTests/SessionManagerTests.swift | 150 +++++++++++-------- 10 files changed, 233 insertions(+), 171 deletions(-) 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 43813b9d..bf4549ee 100644 --- a/Sources/GoTrue/GoTrueClient.swift +++ b/Sources/GoTrue/GoTrueClient.swift @@ -11,7 +11,7 @@ public actor GoTrueClient { 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 @@ -100,7 +100,6 @@ public actor GoTrueClient { } public init(configuration: Configuration) { - let sessionStorage = DefaultSessionStorage() let sessionManager = DefaultSessionManager() let codeVerifierStorage = DefaultCodeVerifierStorage() @@ -113,7 +112,7 @@ public actor GoTrueClient { codeVerifierStorage: codeVerifierStorage, api: api, eventEmitter: eventEmitter, - sessionStorage: sessionStorage + sessionStorage: .live ) } @@ -136,7 +135,11 @@ public actor GoTrueClient { api: api, eventEmitter: eventEmitter, sessionStorage: sessionStorage, - sessionRefresher: self + sessionRefresher: SessionRefresher( + refreshSession: { [weak self] in + try await self?.refreshSession(refreshToken: $0) ?? .empty + } + ) ) ) } @@ -704,6 +707,28 @@ public actor GoTrueClient { ) } + @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) + + 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") } @@ -734,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 eventEmitter.emit(.signedIn) - } - - return session - } catch { - throw error - } - } -} - extension GoTrueClient { public static let didChangeAuthStateNotification = Notification.Name( "DID_CHANGE_AUTH_STATE_NOTIFICATION") diff --git a/Sources/GoTrue/Internal/Dependencies.swift b/Sources/GoTrue/Internal/Dependencies.swift index 484a43dd..720a3c7f 100644 --- a/Sources/GoTrue/Internal/Dependencies.swift +++ b/Sources/GoTrue/Internal/Dependencies.swift @@ -1,7 +1,7 @@ import Foundation @_spi(Internal) import _Helpers -struct Dependencies { +struct Dependencies: Sendable { static let current = LockIsolated(Dependencies?.none) var configuration: GoTrueClient.Configuration diff --git a/Sources/GoTrue/Internal/SessionManager.swift b/Sources/GoTrue/Internal/SessionManager.swift index 0d8e8913..5adc6e9d 100644 --- a/Sources/GoTrue/Internal/SessionManager.swift +++ b/Sources/GoTrue/Internal/SessionManager.swift @@ -2,8 +2,8 @@ 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 { @@ -39,8 +39,7 @@ actor DefaultSessionManager: SessionManager { task = Task { defer { task = nil } - let session = try await sessionRefresher.refreshSession( - refreshToken: currentSession.session.refreshToken) + let session = try await sessionRefresher.refreshSession(currentSession.session.refreshToken) try update(session) return session } @@ -53,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 2a749899..fda73986 100644 --- a/Sources/GoTrue/Internal/SessionStorage.swift +++ b/Sources/GoTrue/Internal/SessionStorage.swift @@ -23,28 +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 { - private var localStorage: GoTrueLocalStorage { - Dependencies.current.value!.configuration.localStorage - } - - 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 81d96182..f51f8f3a 100644 --- a/Sources/GoTrue/Types.swift +++ b/Sources/GoTrue/Types.swift @@ -75,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 { @@ -336,7 +351,7 @@ struct RecoverParams: Codable, Hashable, Sendable { var gotrueMetaSecurity: GoTrueMetaSecurity? } -public enum AuthFlowType { +public enum AuthFlowType: Sendable { case implicit case pkce } diff --git a/Tests/GoTrueTests/GoTrueClientTests.swift b/Tests/GoTrueTests/GoTrueClientTests.swift index 364bf5c9..6bba4d60 100644 --- a/Tests/GoTrueTests/GoTrueClientTests.swift +++ b/Tests/GoTrueTests/GoTrueClientTests.swift @@ -62,7 +62,7 @@ final class GoTrueClientTests: XCTestCase { } ) - api = APIClient(configuration: configuration, sessionManager: sessionManager) + api = APIClient() let sut = GoTrueClient( configuration: configuration, @@ -70,12 +70,7 @@ final class GoTrueClientTests: XCTestCase { codeVerifierStorage: codeVerifierStorage, api: api, eventEmitter: eventEmitter, - mfa: GoTrueMFA( - api: api, - sessionManager: sessionManager, - configuration: configuration, - eventEmitter: eventEmitter - ) + sessionStorage: .mock ) addTeardownBlock { [weak sut] in @@ -89,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 3cde924a..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 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") + 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,11 +283,13 @@ 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 - let sessionManager = DefaultSessionManager(storage: storage) + let sessionManager = DefaultSessionManager() let configuration = GoTrueClient.Configuration( url: clientURL, @@ -292,12 +312,7 @@ final class RequestsTests: XCTestCase { let eventEmitter = DefaultEventEmitter() // TODO: Inject a mocked APIClient - let api = APIClient(configuration: configuration, sessionManager: sessionManager) - - // TODO: Inject a mocked GoTrueMFA - let mfa = GoTrueMFA( - api: api, sessionManager: sessionManager, configuration: configuration, - eventEmitter: eventEmitter) + let api = APIClient() return GoTrueClient( configuration: configuration, @@ -305,7 +320,7 @@ final class RequestsTests: XCTestCase { codeVerifierStorage: CodeVerifierStorageMock(), api: api, eventEmitter: eventEmitter, - mfa: mfa + 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() } From 862131842197082dad89cb1a9ed1ec0ead4e8075 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 27 Oct 2023 09:47:05 -0300 Subject: [PATCH 11/11] Remove commented code --- Tests/GoTrueTests/GoTrueClientTests.swift | 9 --------- 1 file changed, 9 deletions(-) diff --git a/Tests/GoTrueTests/GoTrueClientTests.swift b/Tests/GoTrueTests/GoTrueClientTests.swift index 6bba4d60..977fa4c9 100644 --- a/Tests/GoTrueTests/GoTrueClientTests.swift +++ b/Tests/GoTrueTests/GoTrueClientTests.swift @@ -115,12 +115,3 @@ final class CodeVerifierStorageMock: CodeVerifierStorage { codeVerifier = nil } } -// -//final class EventEmitterMock: EventEmitter { -// func attachListener() async -> (id: UUID, stream: AsyncStream) { -// return (UUID(), AsyncStream.makeStream().stream) -// } -// -// func emit(_ event: AuthChangeEvent, id: UUID?) async { -// } -//}