Skip to content

Commit

Permalink
feat(auth): auto refresh token in background
Browse files Browse the repository at this point in the history
  • Loading branch information
grdsdev committed Apr 8, 2024
1 parent 97d1900 commit d0b31aa
Show file tree
Hide file tree
Showing 4 changed files with 143 additions and 12 deletions.
61 changes: 54 additions & 7 deletions Sources/Auth/AuthClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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] = [:],
Expand All @@ -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 }

Expand All @@ -53,6 +63,7 @@ public actor AuthClient {
self.encoder = encoder
self.decoder = decoder
self.fetch = fetch
self.autoRefreshToken = autoRefreshToken
}
}

Expand Down Expand Up @@ -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:
Expand All @@ -105,15 +118,18 @@ 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,
localStorage: any AuthLocalStorage,
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(
Expand All @@ -124,7 +140,8 @@ public actor AuthClient {
logger: logger,
encoder: encoder,
decoder: decoder,
fetch: fetch
fetch: fetch,
autoRefreshToken: autoRefreshToken
)
)
}
Expand All @@ -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(
Expand Down Expand Up @@ -166,6 +183,10 @@ public actor AuthClient {
mfa = AuthMFA()
admin = AuthAdmin()

if configuration.autoRefreshToken {
autoRefreshToken = AutoRefreshToken()
}

Current = Dependencies(
configuration: configuration,
sessionManager: sessionManager,
Expand All @@ -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.
Expand Down Expand Up @@ -1023,7 +1071,6 @@ public actor AuthClient {
.user.confirmedAt != nil
{
try await sessionManager.update(session)
eventEmitter.emit(.tokenRefreshed, session: session)
}

return session
Expand Down
2 changes: 1 addition & 1 deletion Sources/Auth/Deprecated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
73 changes: 73 additions & 0 deletions Sources/Auth/Internal/AutoRefreshToken.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
//
// AutoRefreshToken.swift
//
//
// Created by Guilherme Souza on 06/04/24.
//

import Foundation

actor AutoRefreshToken {
private var task: Task<Void, Never>?
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)")
}
}
}
19 changes: 15 additions & 4 deletions Sources/Auth/Internal/SessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) }
)
}()
}
Expand All @@ -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
Expand All @@ -54,19 +59,25 @@ 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
}

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
}
}

0 comments on commit d0b31aa

Please sign in to comment.