Skip to content

Add and improve documentation for GoTrue #138

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Oct 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Examples/ProductSample/Data/AuthenticationRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ struct AuthenticationRepositoryImpl: AuthenticationRepository {
}

func authStateListener() async -> AsyncStream<AuthenticationState> {
await client.onAuthStateChange().compactMap { event in
await client.onAuthStateChange().compactMap { event, session in
switch event {
case .initialSession: session != nil ? AuthenticationState.signedIn : .signedOut
case .signedIn: AuthenticationState.signedIn
case .signedOut: AuthenticationState.signedOut
case .passwordRecovery, .tokenRefreshed, .userUpdated, .userDeleted, .mfaChallengeVerified:
Expand Down
133 changes: 81 additions & 52 deletions Sources/GoTrue/GoTrueClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ public typealias AnyJSON = _Helpers.AnyJSON
#endif

public actor GoTrueClient {
/// FetchHandler is a type alias for asynchronous network request handling.
public typealias FetchHandler =
@Sendable (_ request: URLRequest) async throws -> (Data, URLResponse)

/// Configuration struct represents the client configuration.
public struct Configuration: Sendable {
public let url: URL
public var headers: [String: String]
Expand All @@ -20,6 +22,17 @@ public actor GoTrueClient {
public let decoder: JSONDecoder
public let fetch: FetchHandler

/// Initializes a GoTrueClient Configuration with optional parameters.
///
/// - Parameters:
/// - url: The base URL of the GoTrue server.
/// - headers: (Optional) Custom headers to be included in requests.
/// - flowType: (Optional) The authentication flow type. Default is `.implicit`.
/// - localStorage: (Optional) The storage mechanism for local data. Default is a
/// KeychainLocalStorage.
/// - encoder: (Optional) The JSON encoder to use for encoding requests.
/// - decoder: (Optional) The JSON decoder to use for decoding responses.
/// - fetch: (Optional) The asynchronous fetch handler for network requests.
public init(
url: URL,
headers: [String: String] = [:],
Expand Down Expand Up @@ -81,6 +94,17 @@ public actor GoTrueClient {
/// Namespace for accessing multi-factor authentication API.
public let mfa: GoTrueMFA

/// Initializes a GoTrueClient with optional parameters.
///
/// - Parameters:
/// - url: The base URL of the GoTrue server.
/// - headers: (Optional) Custom headers to be included in requests.
/// - flowType: (Optional) The authentication flow type. Default is `.implicit`.
/// - localStorage: (Optional) The storage mechanism for local data. Default is a
/// KeychainLocalStorage.
/// - encoder: (Optional) The JSON encoder to use for encoding requests.
/// - decoder: (Optional) The JSON decoder to use for decoding responses.
/// - fetch: (Optional) The asynchronous fetch handler for network requests.
public init(
url: URL,
headers: [String: String] = [:],
Expand All @@ -103,6 +127,10 @@ public actor GoTrueClient {
)
}

/// Initializes a GoTrueClient with a specific configuration.
///
/// - Parameters:
/// - configuration: The client configuration.
public init(configuration: Configuration) {
let api = APIClient()

Expand Down Expand Up @@ -144,12 +172,13 @@ public actor GoTrueClient {
)
}

public func onAuthStateChange() async -> AsyncStream<AuthChangeEvent> {
/// Listen for auth state changes.
///
/// An `.initialSession` is always emitted when this method is called.
public func onAuthStateChange() async -> AsyncStream<(event: AuthChangeEvent, session: Session?)> {
let (id, stream) = await eventEmitter.attachListener()

Task { [id] in
_debug("emitInitialSessionTask start")
defer { _debug("emitInitialSessionTask end") }
await emitInitialSession(forStreamWithID: id)
}

Expand Down Expand Up @@ -192,25 +221,6 @@ public actor GoTrueClient {
)
}

private func prepareForPKCE() -> (codeChallenge: String?, codeChallengeMethod: String?) {
if configuration.flowType == .pkce {
let codeVerifier = PKCE.generateCodeVerifier()

do {
try codeVerifierStorage.storeCodeVerifier(codeVerifier)
} catch {
_debug("Error storing code verifier: \(error)")
}

let codeChallenge = PKCE.generateCodeChallenge(from: codeVerifier)
let codeChallengeMethod = codeVerifier == codeChallenge ? "plain" : "s256"

return (codeChallenge, codeChallengeMethod)
}

return (nil, nil)
}

/// Creates a new user.
/// - Parameters:
/// - phone: User's phone number with international prefix.
Expand Down Expand Up @@ -248,7 +258,7 @@ public actor GoTrueClient {

if let session = response.session {
try await sessionManager.update(session)
await eventEmitter.emit(.signedIn)
await eventEmitter.emit(.signedIn, session: session)
}

return response
Expand Down Expand Up @@ -308,7 +318,7 @@ public actor GoTrueClient {

if session.user.emailConfirmedAt != nil || session.user.confirmedAt != nil {
try await sessionManager.update(session)
await eventEmitter.emit(.signedIn)
await eventEmitter.emit(.signedIn, session: session)
}

return session
Expand Down Expand Up @@ -411,7 +421,7 @@ public actor GoTrueClient {
try codeVerifierStorage.deleteCodeVerifier()

try await sessionManager.update(session)
await eventEmitter.emit(.signedIn)
await eventEmitter.emit(.signedIn, session: session)

return session
} catch {
Expand Down Expand Up @@ -524,10 +534,10 @@ public actor GoTrueClient {
)

try await sessionManager.update(session)
await eventEmitter.emit(.signedIn)
await eventEmitter.emit(.signedIn, session: session)

if let type = params.first(where: { $0.name == "type" })?.value, type == "recovery" {
await eventEmitter.emit(.passwordRecovery)
await eventEmitter.emit(.passwordRecovery, session: session)
}

return session
Expand Down Expand Up @@ -571,7 +581,7 @@ public actor GoTrueClient {
}

try await sessionManager.update(session)
await eventEmitter.emit(.signedIn)
await eventEmitter.emit(.signedIn, session: session)
return session
}

Expand All @@ -586,9 +596,9 @@ public actor GoTrueClient {
)
)
await sessionManager.remove()
await eventEmitter.emit(.signedOut)
await eventEmitter.emit(.signedOut, session: nil)
} catch {
await eventEmitter.emit(.signedOut)
await eventEmitter.emit(.signedOut, session: nil)
throw error
}
}
Expand Down Expand Up @@ -662,7 +672,7 @@ public actor GoTrueClient {

if let session = response.session {
try await sessionManager.update(session)
await eventEmitter.emit(.signedIn)
await eventEmitter.emit(.signedIn, session: session)
}

return response
Expand Down Expand Up @@ -702,7 +712,7 @@ public actor GoTrueClient {
).decoded(as: User.self, decoder: configuration.decoder)
session.user = updatedUser
try await sessionManager.update(session)
await eventEmitter.emit(.userUpdated)
await eventEmitter.emit(.userUpdated, session: session)
return updatedUser
}

Expand Down Expand Up @@ -733,14 +743,24 @@ public actor GoTrueClient {
)
}

/// Refresh and return a new session, regardless of expiry status.
/// - Parameter refreshToken: The optional refresh token to use for refreshing the session. If
/// none is provided then this method tries to load the refresh token from the current session.
/// - Returns: A new session.
@discardableResult
public func refreshSession(refreshToken: String) async throws -> Session {
public func refreshSession(refreshToken: String? = nil) async throws -> Session {
var credentials = UserCredentials(refreshToken: refreshToken)
if credentials.refreshToken == nil {
credentials.refreshToken = try await sessionManager.session(shouldValidateExpiration: false)
.refreshToken
}

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))
body: configuration.encoder.encode(credentials)
)
).decoded(as: Session.self, decoder: configuration.decoder)

Expand All @@ -749,33 +769,41 @@ public actor GoTrueClient {
.user.confirmedAt != nil
{
try await sessionManager.update(session)
await eventEmitter.emit(.tokenRefreshed)
await eventEmitter.emit(.tokenRefreshed, session: session)
}

return session
}

/// Refresh and return a new session, regardless of expiry status.
@discardableResult
public func refreshSession() async throws -> Session {
let refreshToken = try await session.refreshToken
return try await refreshSession(refreshToken: refreshToken)
}

private func emitInitialSession(forStreamWithID id: UUID) async {
_debug("start")
defer { _debug("end") }

let session = try? await session
await eventEmitter.emit(session != nil ? .signedIn : .signedOut, id)
await eventEmitter.emit(.initialSession, session, id)
}

private func _debug(
_ message: String,
function: StaticString = #function,
line: UInt = #line
) {
debugPrint("[GoTrueClient] \(function):\(line) \(message)")
private func prepareForPKCE() -> (codeChallenge: String?, codeChallengeMethod: String?) {
if configuration.flowType == .pkce {
let codeVerifier = PKCE.generateCodeVerifier()

do {
try codeVerifierStorage.storeCodeVerifier(codeVerifier)
} catch {
assertionFailure(
"""
An error occurred while storing the code verifier,
PKCE flow may not work as expected.

Error: \(error.localizedDescription)
"""
)
}

let codeChallenge = PKCE.generateCodeChallenge(from: codeVerifier)
let codeChallengeMethod = codeVerifier == codeChallenge ? "plain" : "s256"

return (codeChallenge, codeChallengeMethod)
}

return (nil, nil)
}

private func isImplicitGrantFlow(url: URL) -> Bool {
Expand All @@ -793,6 +821,7 @@ public actor GoTrueClient {
}

extension GoTrueClient {
/// Notification posted when an auth state event is triggered.
public static let didChangeAuthStateNotification = Notification.Name(
"DID_CHANGE_AUTH_STATE_NOTIFICATION"
)
Expand Down
2 changes: 1 addition & 1 deletion Sources/GoTrue/GoTrueMFA.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public actor GoTrueMFA {

try await sessionManager.update(response)

await eventEmitter.emit(.mfaChallengeVerified)
await eventEmitter.emit(.mfaChallengeVerified, session: response)

return response
}
Expand Down
22 changes: 13 additions & 9 deletions Sources/GoTrue/Internal/EventEmitter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,25 @@ import Foundation
@_spi(Internal) import _Helpers

struct EventEmitter: Sendable {
var attachListener: @Sendable () async -> (id: UUID, stream: AsyncStream<AuthChangeEvent>)
var emit: @Sendable (_ event: AuthChangeEvent, _ id: UUID?) async -> Void
var attachListener: @Sendable () async -> (id: UUID, stream: AsyncStream<(event: AuthChangeEvent, session: Session?)>)
var emit: @Sendable (_ event: AuthChangeEvent, _ session: Session?, _ id: UUID?) async -> Void
}

extension EventEmitter {
func emit(_ event: AuthChangeEvent) async {
await emit(event, nil)
func emit(_ event: AuthChangeEvent, session: Session?) async {
await emit(event, session, nil)
}
}

extension EventEmitter {
static var live: Self = {
let continuations = ActorIsolated([UUID: AsyncStream<AuthChangeEvent>.Continuation]())
let continuations = ActorIsolated([UUID: AsyncStream<(event: AuthChangeEvent, session: Session?)>.Continuation]())

return Self(
attachListener: {
let id = UUID()

let (stream, continuation) = AsyncStream<AuthChangeEvent>.makeStream()
let (stream, continuation) = AsyncStream<(event: AuthChangeEvent, session: Session?)>.makeStream()

continuation.onTermination = { [id] _ in
continuations.withValue {
Expand All @@ -34,12 +34,16 @@ extension EventEmitter {

return (id, stream)
},
emit: { event, id in
emit: { event, session, id in
NotificationCenter.default.post(
name: GoTrueClient.didChangeAuthStateNotification,
object: nil
)
if let id {
continuations.value[id]?.yield(event)
continuations.value[id]?.yield((event, session))
} else {
for continuation in continuations.value.values {
continuation.yield(event)
continuation.yield((event, session))
}
}
}
Expand Down
12 changes: 8 additions & 4 deletions Sources/GoTrue/Internal/SessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,20 @@ struct SessionRefresher: Sendable {
}

struct SessionManager: Sendable {
var session: @Sendable () async throws -> Session
var session: @Sendable (_ shouldValidateExpiration: Bool) async throws -> Session
var update: @Sendable (_ session: Session) async throws -> Void
var remove: @Sendable () async -> Void

func session(shouldValidateExpiration: Bool = true) async throws -> Session {
try await session(shouldValidateExpiration)
}
}

extension SessionManager {
static var live: Self = {
let manager = _LiveSessionManager()
return Self(
session: { try await manager.session() },
session: { try await manager.session(shouldValidateExpiration: $0) },
update: { try await manager.update($0) },
remove: { await manager.remove() }
)
Expand All @@ -34,7 +38,7 @@ actor _LiveSessionManager {
Dependencies.current.value!.sessionRefresher
}

func session() async throws -> Session {
func session(shouldValidateExpiration: Bool) async throws -> Session {
if let task {
return try await task.value
}
Expand All @@ -43,7 +47,7 @@ actor _LiveSessionManager {
throw GoTrueError.sessionNotFound
}

if currentSession.isValid {
if currentSession.isValid || !shouldValidateExpiration {
return currentSession.session
}

Expand Down
1 change: 1 addition & 0 deletions Sources/GoTrue/Types.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation

public enum AuthChangeEvent: String, Sendable {
case initialSession = "INITIAL_SESSION"
case passwordRecovery = "PASSWORD_RECOVERY"
case signedIn = "SIGNED_IN"
case signedOut = "SIGNED_OUT"
Expand Down
Loading