From d0b31aa239223bd1e58788202f0f6c2824302973 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 8 Apr 2024 05:22:37 -0300 Subject: [PATCH] feat(auth): auto refresh token in background --- Sources/Auth/AuthClient.swift | 61 ++++++++++++++-- Sources/Auth/Deprecated.swift | 2 +- Sources/Auth/Internal/AutoRefreshToken.swift | 73 ++++++++++++++++++++ Sources/Auth/Internal/SessionManager.swift | 19 +++-- 4 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 Sources/Auth/Internal/AutoRefreshToken.swift diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 25f605a9..07868e41 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -5,7 +5,11 @@ import Foundation import FoundationNetworking #endif -public actor AuthClient { +#if canImport(UIKit) + import UIKit +#endif + +public final class AuthClient: Sendable { /// FetchHandler is a type alias for asynchronous network request handling. public typealias FetchHandler = @Sendable ( _ request: URLRequest @@ -22,6 +26,9 @@ public actor AuthClient { public let decoder: JSONDecoder public let fetch: FetchHandler + /// Whether the client should auto refresh token in background. + public let autoRefreshToken: Bool + /// Initializes a AuthClient Configuration with optional parameters. /// /// - Parameters: @@ -33,6 +40,8 @@ public actor AuthClient { /// - encoder: The JSON encoder to use for encoding requests. /// - decoder: The JSON decoder to use for decoding responses. /// - fetch: The asynchronous fetch handler for network requests. + /// - autoRefreshToken: Whether the client should auto refresh token in background, defaults + /// to true. public init( url: URL, headers: [String: String] = [:], @@ -41,7 +50,8 @@ public actor AuthClient { logger: (any SupabaseLogger)? = nil, encoder: JSONEncoder = AuthClient.Configuration.jsonEncoder, decoder: JSONDecoder = AuthClient.Configuration.jsonDecoder, - fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) } + fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) }, + autoRefreshToken: Bool = true ) { let headers = headers.merging(Configuration.defaultHeaders) { l, _ in l } @@ -53,6 +63,7 @@ public actor AuthClient { self.encoder = encoder self.decoder = decoder self.fetch = fetch + self.autoRefreshToken = autoRefreshToken } } @@ -94,6 +105,8 @@ public actor AuthClient { /// key in the client. public let admin: AuthAdmin + private var autoRefreshToken: AutoRefreshToken? + /// Initializes a AuthClient with optional parameters. /// /// - Parameters: @@ -105,7 +118,9 @@ public actor AuthClient { /// - encoder: The JSON encoder to use for encoding requests. /// - decoder: The JSON decoder to use for decoding responses. /// - fetch: The asynchronous fetch handler for network requests. - public init( + /// - autoRefreshToken: Whether the client should auto refresh token in background, defaults to + /// true. + public convenience init( url: URL, headers: [String: String] = [:], flowType: AuthFlowType = AuthClient.Configuration.defaultFlowType, @@ -113,7 +128,8 @@ public actor AuthClient { logger: (any SupabaseLogger)? = nil, encoder: JSONEncoder = AuthClient.Configuration.jsonEncoder, decoder: JSONDecoder = AuthClient.Configuration.jsonDecoder, - fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) } + fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) }, + autoRefreshToken: Bool = true ) { self.init( configuration: Configuration( @@ -124,7 +140,8 @@ public actor AuthClient { logger: logger, encoder: encoder, decoder: decoder, - fetch: fetch + fetch: fetch, + autoRefreshToken: autoRefreshToken ) ) } @@ -133,7 +150,7 @@ public actor AuthClient { /// /// - Parameters: /// - configuration: The client configuration. - public init(configuration: Configuration) { + public convenience init(configuration: Configuration) { let api = APIClient.live( configuration: configuration, http: HTTPClient( @@ -166,6 +183,10 @@ public actor AuthClient { mfa = AuthMFA() admin = AuthAdmin() + if configuration.autoRefreshToken { + autoRefreshToken = AutoRefreshToken() + } + Current = Dependencies( configuration: configuration, sessionManager: sessionManager, @@ -180,6 +201,33 @@ public actor AuthClient { codeVerifierStorage: codeVerifierStorage, logger: logger ) + + NotificationCenter.default.addObserver( + forName: UIApplication.willResignActiveNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.appDidEnterBackground() + } + NotificationCenter.default.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.appDidBecomeActive() + } + } + + private func appDidEnterBackground() { + Task { + await autoRefreshToken?.stop() + } + } + + private func appDidBecomeActive() { + Task { + await autoRefreshToken?.start() + } } /// Listen for auth state changes. @@ -1023,7 +1071,6 @@ public actor AuthClient { .user.confirmedAt != nil { try await sessionManager.update(session) - eventEmitter.emit(.tokenRefreshed, session: session) } return session diff --git a/Sources/Auth/Deprecated.swift b/Sources/Auth/Deprecated.swift index cd8d4e8f..3d547e76 100644 --- a/Sources/Auth/Deprecated.swift +++ b/Sources/Auth/Deprecated.swift @@ -105,7 +105,7 @@ extension AuthClient { deprecated, message: "Replace usages of this initializer with new init(url:headers:flowType:localStorage:logger:encoder:decoder:fetch)" ) - public init( + public convenience init( url: URL, headers: [String: String] = [:], flowType: AuthFlowType = Configuration.defaultFlowType, diff --git a/Sources/Auth/Internal/AutoRefreshToken.swift b/Sources/Auth/Internal/AutoRefreshToken.swift new file mode 100644 index 00000000..970d8c1f --- /dev/null +++ b/Sources/Auth/Internal/AutoRefreshToken.swift @@ -0,0 +1,73 @@ +// +// AutoRefreshToken.swift +// +// +// Created by Guilherme Souza on 06/04/24. +// + +import Foundation + +actor AutoRefreshToken { + private var task: Task? + private let autoRefreshTickDuration: TimeInterval = 30 + private let autoRefreshTickThreshold = 3 + + @Dependency(\.sessionManager) var sessionManager + @Dependency(\.logger) var logger + + func start() { + stop() + + logger?.debug("start") + + task = Task { + while !Task.isCancelled { + await autoRefreshTokenTick() + try? await Task.sleep(nanoseconds: UInt64(autoRefreshTickDuration) * NSEC_PER_SEC) + } + } + } + + func stop() { + task?.cancel() + task = nil + } + + private func autoRefreshTokenTick() async { + logger?.debug("begin") + defer { + logger?.debug("end") + } + + let now = Date() + + do { + let session = try await sessionManager.session() + if Task.isCancelled { + return + } + + guard let expiresAt = session.expiresAt else { + return + } + + // session will expire in this many ticks (or has already expired if <= 0) + let expiresInTicks = Int((expiresAt - now.timeIntervalSince1970) / autoRefreshTickDuration) + + logger? + .debug( + "access token expires in \(expiresInTicks) ticks, a tick last \(autoRefreshTickDuration)s, refresh threshold is \(autoRefreshTickThreshold) ticks" + ) + + if expiresInTicks <= autoRefreshTickThreshold { + _ = try await sessionManager.refreshSession(session.refreshToken) + } + + } catch AuthError.sessionNotFound { + logger?.debug("no session") + return + } catch { + logger?.error("Auto refresh tick failed with error: \(error)") + } + } +} diff --git a/Sources/Auth/Internal/SessionManager.swift b/Sources/Auth/Internal/SessionManager.swift index f91a0107..46913d65 100644 --- a/Sources/Auth/Internal/SessionManager.swift +++ b/Sources/Auth/Internal/SessionManager.swift @@ -9,6 +9,7 @@ struct SessionManager: Sendable { var session: @Sendable (_ shouldValidateExpiration: Bool) async throws -> Session var update: @Sendable (_ session: Session) async throws -> Void var remove: @Sendable () async -> Void + var refreshSession: @Sendable (_ refreshToken: String) async throws -> Session } extension SessionManager { @@ -24,7 +25,8 @@ extension SessionManager { return SessionManager( session: { try await manager.session(shouldValidateExpiration: $0) }, update: { try await manager.update($0) }, - remove: { await manager.remove() } + remove: { await manager.remove() }, + refreshSession: { try await manager.refreshSession($0) } ) }() } @@ -38,6 +40,9 @@ private actor _DefaultSessionManager { @Dependency(\.sessionRefresher) private var sessionRefresher: SessionRefresher + @Dependency(\.eventEmitter) + private var eventEmitter: EventEmitter + func session(shouldValidateExpiration: Bool) async throws -> Session { if let task { return try await task.value @@ -54,9 +59,7 @@ private actor _DefaultSessionManager { task = Task { defer { task = nil } - let session = try await sessionRefresher.refreshSession(currentSession.session.refreshToken) - try update(session) - return session + return try await refreshSession(currentSession.session.refreshToken) } return try await task!.value @@ -64,9 +67,17 @@ private actor _DefaultSessionManager { func update(_ session: Session) throws { try storage.storeSession(StoredSession(session: session)) + eventEmitter.emit(.tokenRefreshed, session: session) } func remove() { try? storage.deleteSession() } + + @discardableResult + func refreshSession(_ refreshToken: String) async throws -> Session { + let session = try await sessionRefresher.refreshSession(refreshToken) + try update(session) + return session + } }