Skip to content

Commit 35ac278

Browse files
authored
feat(auth): retry auth requests, and schedule next refresh retry in background (#395)
* feat(auth): auto refresh token * Schedule next token refresh * move auto refresh token logic to actor * dont expose StoredSession * generic RetryableError * Use request interceptor for retrying requests * revert * Revert retryable error * schedule refresh when accessing session for the first time * fix tests
1 parent 7222c4b commit 35ac278

13 files changed

+215
-128
lines changed

Sources/Auth/AuthClient.swift

Lines changed: 27 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ public final class AuthClient: Sendable {
1717
private var date: @Sendable () -> Date { Current.date }
1818
private var sessionManager: SessionManager { Current.sessionManager }
1919
private var eventEmitter: AuthStateChangeEventEmitter { Current.eventEmitter }
20+
private var logger: (any SupabaseLogger)? { Current.logger }
21+
private var storage: any AuthLocalStorage { Current.configuration.localStorage }
2022

2123
/// Returns the session, refreshing it if necessary.
2224
///
@@ -27,6 +29,20 @@ public final class AuthClient: Sendable {
2729
}
2830
}
2931

32+
/// Returns the current session, if any.
33+
///
34+
/// The session returned by this property may be expired. Use ``session`` for a session that is guaranteed to be valid.
35+
public var currentSession: Session? {
36+
try? storage.getSession()
37+
}
38+
39+
/// Returns the current user, if any.
40+
///
41+
/// The user returned by this property may be outdated. Use ``user(jwt:)`` method to get an up-to-date user instance.
42+
public var currentUser: User? {
43+
try? storage.getSession()?.user
44+
}
45+
3046
/// Namespace for accessing multi-factor authentication API.
3147
public let mfa = AuthMFA()
3248
/// Namespace for the GoTrue admin methods.
@@ -41,9 +57,6 @@ public final class AuthClient: Sendable {
4157
public init(configuration: Configuration) {
4258
Current = Dependencies(
4359
configuration: configuration,
44-
sessionRefresher: SessionRefresher { [weak self] in
45-
try await self?.refreshSession(refreshToken: $0) ?? .empty
46-
},
4760
http: HTTPClient(configuration: configuration)
4861
)
4962
}
@@ -158,13 +171,14 @@ public final class AuthClient: Sendable {
158171

159172
private func _signUp(request: HTTPRequest) async throws -> AuthResponse {
160173
await sessionManager.remove()
174+
161175
let response = try await api.execute(request).decoded(
162176
as: AuthResponse.self,
163177
decoder: configuration.decoder
164178
)
165179

166180
if let session = response.session {
167-
try await sessionManager.update(session)
181+
await sessionManager.update(session)
168182
eventEmitter.emit(.signedIn, session: session)
169183
}
170184

@@ -264,7 +278,7 @@ public final class AuthClient: Sendable {
264278
decoder: configuration.decoder
265279
)
266280

267-
try await sessionManager.update(session)
281+
await sessionManager.update(session)
268282
eventEmitter.emit(.signedIn, session: session)
269283

270284
return session
@@ -445,7 +459,7 @@ public final class AuthClient: Sendable {
445459

446460
codeVerifierStorage.set(nil)
447461

448-
try await sessionManager.update(session)
462+
await sessionManager.update(session)
449463
eventEmitter.emit(.signedIn, session: session)
450464

451465
return session
@@ -640,7 +654,7 @@ public final class AuthClient: Sendable {
640654
user: user
641655
)
642656

643-
try await sessionManager.update(session)
657+
await sessionManager.update(session)
644658
eventEmitter.emit(.signedIn, session: session)
645659

646660
if let type = params["type"], type == "recovery" {
@@ -688,7 +702,7 @@ public final class AuthClient: Sendable {
688702
)
689703
}
690704

691-
try await sessionManager.update(session)
705+
await sessionManager.update(session)
692706
eventEmitter.emit(.signedIn, session: session)
693707
return session
694708
}
@@ -805,7 +819,7 @@ public final class AuthClient: Sendable {
805819
)
806820

807821
if let session = response.session {
808-
try await sessionManager.update(session)
822+
await sessionManager.update(session)
809823
eventEmitter.emit(.signedIn, session: session)
810824
}
811825

@@ -889,20 +903,6 @@ public final class AuthClient: Sendable {
889903
)
890904
}
891905

892-
/// Returns the current session, if any.
893-
///
894-
/// The session returned by this property may be expired. Use ``session`` for a session that is guaranteed to be valid.
895-
public var currentSession: Session? {
896-
try? configuration.localStorage.getSession()?.session
897-
}
898-
899-
/// Returns the current user, if any.
900-
///
901-
/// The user returned by this property may be outdated. Use ``user(jwt:)`` method to get an up-to-date user instance.
902-
public var currentUser: User? {
903-
try? configuration.localStorage.getSession()?.session.user
904-
}
905-
906906
/// Gets the current user details if there is an existing session.
907907
/// - Parameter jwt: Takes in an optional access token jwt. If no jwt is provided, user() will
908908
/// attempt to get the jwt from the current session.
@@ -945,7 +945,7 @@ public final class AuthClient: Sendable {
945945
)
946946
).decoded(as: User.self, decoder: configuration.decoder)
947947
session.user = updatedUser
948-
try await sessionManager.update(session)
948+
await sessionManager.update(session)
949949
eventEmitter.emit(.userUpdated, session: session)
950950
return updatedUser
951951
}
@@ -1094,30 +1094,11 @@ public final class AuthClient: Sendable {
10941094
/// - Returns: A new session.
10951095
@discardableResult
10961096
public func refreshSession(refreshToken: String? = nil) async throws -> Session {
1097-
var credentials = UserCredentials(refreshToken: refreshToken)
1098-
if credentials.refreshToken == nil {
1099-
credentials.refreshToken = try await sessionManager.session(shouldValidateExpiration: false)
1100-
.refreshToken
1097+
guard let refreshToken = refreshToken ?? currentSession?.refreshToken else {
1098+
throw AuthError.sessionNotFound
11011099
}
11021100

1103-
let session = try await api.execute(
1104-
.init(
1105-
url: configuration.url.appendingPathComponent("token"),
1106-
method: .post,
1107-
query: [URLQueryItem(name: "grant_type", value: "refresh_token")],
1108-
body: configuration.encoder.encode(credentials)
1109-
)
1110-
).decoded(as: Session.self, decoder: configuration.decoder)
1111-
1112-
if session.user.phoneConfirmedAt != nil || session.user.emailConfirmedAt != nil
1113-
|| session
1114-
.user.confirmedAt != nil
1115-
{
1116-
try await sessionManager.update(session)
1117-
eventEmitter.emit(.tokenRefreshed, session: session)
1118-
}
1119-
1120-
return session
1101+
return try await sessionManager.refreshSession(refreshToken)
11211102
}
11221103

11231104
private func emitInitialSession(forToken token: ObservationToken) async {

Sources/Auth/AuthMFA.swift

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public struct AuthMFA: Sendable {
6060
)
6161
).decoded(decoder: decoder)
6262

63-
try await sessionManager.update(response)
63+
await sessionManager.update(response)
6464

6565
eventEmitter.emit(.mfaChallengeVerified, session: response, token: nil)
6666

@@ -116,9 +116,7 @@ public struct AuthMFA: Sendable {
116116
/// Returns the Authenticator Assurance Level (AAL) for the active session.
117117
///
118118
/// - Returns: An authentication response with the Authenticator Assurance Level.
119-
public func getAuthenticatorAssuranceLevel() async throws
120-
-> AuthMFAGetAuthenticatorAssuranceLevelResponse
121-
{
119+
public func getAuthenticatorAssuranceLevel() async throws -> AuthMFAGetAuthenticatorAssuranceLevelResponse {
122120
do {
123121
let session = try await sessionManager.session()
124122
let payload = try decode(jwt: session.accessToken)

Sources/Auth/Internal/APIClient.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ extension HTTPClient {
88
interceptors.append(LoggerInterceptor(logger: logger))
99
}
1010

11+
interceptors.append(
12+
RetryRequestInterceptor(
13+
retryableHTTPMethods: RetryRequestInterceptor.defaultRetryableHTTPMethods.union(
14+
[.post] // Add POST method so refresh token are also retried.
15+
)
16+
)
17+
)
18+
1119
self.init(fetch: configuration.fetch, interceptors: interceptors)
1220
}
1321
}

Sources/Auth/Internal/Dependencies.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@ import Foundation
44

55
struct Dependencies: Sendable {
66
var configuration: AuthClient.Configuration
7-
var sessionRefresher: SessionRefresher
87
var http: any HTTPClientType
9-
var sessionManager = SessionManager()
8+
var sessionManager = SessionManager.live
109
var api = APIClient()
1110

1211
var eventEmitter: AuthStateChangeEventEmitter = .shared

Sources/Auth/Internal/Helpers.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import _Helpers
12
import Foundation
23

34
/// Extracts parameters encoded in the URL both in the query and fragment.
Lines changed: 106 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,132 @@
11
import _Helpers
22
import Foundation
33

4-
struct SessionRefresher: Sendable {
4+
struct SessionManager: Sendable {
5+
var session: @Sendable () async throws -> Session
56
var refreshSession: @Sendable (_ refreshToken: String) async throws -> Session
6-
}
77

8-
actor SessionManager {
9-
private var task: Task<Session, any Error>?
8+
var update: @Sendable (_ session: Session) async -> Void
9+
var remove: @Sendable () async -> Void
10+
}
1011

11-
private var storage: any AuthLocalStorage {
12-
Current.configuration.localStorage
12+
extension SessionManager {
13+
static var live: Self {
14+
let instance = LiveSessionManager()
15+
return Self(
16+
session: { try await instance.session() },
17+
refreshSession: { try await instance.refreshSession($0) },
18+
update: { await instance.update($0) },
19+
remove: { await instance.remove() }
20+
)
1321
}
22+
}
23+
24+
private actor LiveSessionManager {
25+
private var configuration: AuthClient.Configuration { Current.configuration }
26+
private var storage: any AuthLocalStorage { Current.configuration.localStorage }
27+
private var eventEmitter: AuthStateChangeEventEmitter { Current.eventEmitter }
28+
private var logger: (any SupabaseLogger)? { Current.logger }
29+
private var api: APIClient { Current.api }
30+
31+
private var inFlightRefreshTask: Task<Session, any Error>?
32+
private var scheduledNextRefreshTask: Task<Void, Never>?
33+
34+
func session() async throws -> Session {
35+
guard let currentSession = try storage.getSession() else {
36+
throw AuthError.sessionNotFound
37+
}
38+
39+
if currentSession.isValid {
40+
scheduleNextTokenRefresh(currentSession)
1441

15-
private var sessionRefresher: SessionRefresher {
16-
Current.sessionRefresher
42+
return currentSession
43+
}
44+
45+
return try await refreshSession(currentSession.refreshToken)
1746
}
1847

19-
func session(shouldValidateExpiration: Bool = true) async throws -> Session {
20-
if let task {
21-
return try await task.value
48+
func refreshSession(_ refreshToken: String) async throws -> Session {
49+
logger?.debug("begin")
50+
defer { logger?.debug("end") }
51+
52+
if let inFlightRefreshTask {
53+
logger?.debug("refresh already in flight")
54+
return try await inFlightRefreshTask.value
2255
}
2356

24-
task = Task {
25-
defer { task = nil }
57+
inFlightRefreshTask = Task {
58+
logger?.debug("refresh task started")
2659

27-
guard let currentSession = try storage.getSession() else {
28-
throw AuthError.sessionNotFound
60+
defer {
61+
inFlightRefreshTask = nil
62+
logger?.debug("refresh task ended")
2963
}
3064

31-
if currentSession.isValid || !shouldValidateExpiration {
32-
return currentSession.session
33-
}
65+
let session = try await api.execute(
66+
HTTPRequest(
67+
url: configuration.url.appendingPathComponent("token"),
68+
method: .post,
69+
query: [
70+
URLQueryItem(name: "grant_type", value: "refresh_token"),
71+
],
72+
body: configuration.encoder.encode(UserCredentials(refreshToken: refreshToken))
73+
)
74+
)
75+
.decoded(as: Session.self, decoder: configuration.decoder)
76+
77+
update(session)
78+
eventEmitter.emit(.tokenRefreshed, session: session)
79+
80+
scheduleNextTokenRefresh(session)
3481

35-
let session = try await sessionRefresher.refreshSession(currentSession.session.refreshToken)
36-
try update(session)
3782
return session
3883
}
3984

40-
return try await task!.value
85+
return try await inFlightRefreshTask!.value
4186
}
4287

43-
func update(_ session: Session) throws {
44-
try storage.storeSession(StoredSession(session: session))
88+
func update(_ session: Session) {
89+
do {
90+
try storage.storeSession(session)
91+
} catch {
92+
logger?.error("Failed to store session: \(error)")
93+
}
4594
}
4695

4796
func remove() {
48-
try? storage.deleteSession()
97+
do {
98+
try storage.deleteSession()
99+
} catch {
100+
logger?.error("Failed to remove session: \(error)")
101+
}
102+
}
103+
104+
private func scheduleNextTokenRefresh(_ refreshedSession: Session, source: StaticString = #function) {
105+
logger?.debug("source: \(source)")
106+
107+
guard scheduledNextRefreshTask == nil else {
108+
logger?.debug("source: \(source) refresh task already scheduled")
109+
return
110+
}
111+
112+
scheduledNextRefreshTask = Task {
113+
defer { scheduledNextRefreshTask = nil }
114+
115+
let expiresAt = Date(timeIntervalSince1970: refreshedSession.expiresAt)
116+
let expiresIn = expiresAt.timeIntervalSinceNow
117+
118+
// if expiresIn < 0, it will refresh right away.
119+
let timeToRefresh = max(expiresIn * 0.9, 0)
120+
121+
logger?.debug("source: \(source) scheduled next token refresh in: \(timeToRefresh)s")
122+
123+
try? await Task.sleep(nanoseconds: NSEC_PER_SEC * UInt64(timeToRefresh))
124+
125+
if Task.isCancelled {
126+
return
127+
}
128+
129+
_ = try? await refreshSession(refreshedSession.refreshToken)
130+
}
49131
}
50132
}

0 commit comments

Comments
 (0)