Skip to content

Commit 2509486

Browse files
committed
fix: single-flight refresh guard — prevent race condition in JS + Swift SDK (@authon/js@0.7.11)
1 parent 14688c2 commit 2509486

8 files changed

Lines changed: 77 additions & 22 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Official SDKs for [Authon](https://authon.dev) — a modern authentication platf
1212
| Package | Version | Description | npm |
1313
|---------|---------|-------------|-----|
1414
| [`@authon/shared`](./packages/shared) | 0.3.0 | Shared types and constants for all Authon SDKs | [npm](https://www.npmjs.com/package/@authon/shared) |
15-
| [`@authon/js`](./packages/js) | 0.7.10 | Core browser SDK — ShadowDOM modal, OAuth, sessions, CAPTCHA, i18n (21 languages) | [npm](https://www.npmjs.com/package/@authon/js) |
15+
| [`@authon/js`](./packages/js) | 0.7.11 | Core browser SDK — ShadowDOM modal, OAuth, sessions, CAPTCHA, i18n (21 languages) | [npm](https://www.npmjs.com/package/@authon/js) |
1616
| [`@authon/react`](./packages/react) | 0.3.2 | Provider, hooks, and components for React | [npm](https://www.npmjs.com/package/@authon/react) |
1717
| [`@authon/nextjs`](./packages/nextjs) | 0.3.0 | Middleware and React components for Next.js | [npm](https://www.npmjs.com/package/@authon/nextjs) |
1818
| [`@authon/vue`](./packages/vue) | 0.3.3 | Plugin, composables, and components for Vue 3 | [npm](https://www.npmjs.com/package/@authon/vue) |

packages/js/dist/index.cjs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2494,6 +2494,7 @@ var SessionManager = class _SessionManager {
24942494
publishableKey;
24952495
storageKey;
24962496
refreshRetryCount = 0;
2497+
refreshInFlight = null;
24972498
static MAX_REFRESH_RETRIES = 3;
24982499
static RETRY_DELAYS = [3, 10, 30];
24992500
// seconds
@@ -2577,6 +2578,17 @@ var SessionManager = class _SessionManager {
25772578
this.refreshTimer = setTimeout(() => this.refresh(), refreshIn);
25782579
}
25792580
async refresh() {
2581+
if (this.refreshInFlight) return this.refreshInFlight;
2582+
if (!this.refreshToken) {
2583+
this.clearSession();
2584+
return null;
2585+
}
2586+
this.refreshInFlight = this._doRefresh();
2587+
const result = await this.refreshInFlight;
2588+
this.refreshInFlight = null;
2589+
return result;
2590+
}
2591+
async _doRefresh() {
25802592
if (!this.refreshToken) {
25812593
this.clearSession();
25822594
return null;

packages/js/dist/index.cjs.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/js/dist/index.js

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/js/dist/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/js/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@authon/js",
3-
"version": "0.7.10",
3+
"version": "0.7.11",
44
"description": "Authon core browser SDK — ShadowDOM login modal, OAuth, session management",
55
"type": "module",
66
"main": "./dist/index.cjs",

packages/js/src/session.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export class SessionManager {
99
private publishableKey: string;
1010
private storageKey: string;
1111
private refreshRetryCount = 0;
12+
private refreshInFlight: Promise<AuthTokens | null> | null = null;
1213
private static readonly MAX_REFRESH_RETRIES = 3;
1314
private static readonly RETRY_DELAYS = [3, 10, 30]; // seconds
1415

@@ -102,6 +103,21 @@ export class SessionManager {
102103
}
103104

104105
async refresh(): Promise<AuthTokens | null> {
106+
// Single-flight: if refresh is already in progress, return that promise
107+
if (this.refreshInFlight) return this.refreshInFlight;
108+
109+
if (!this.refreshToken) {
110+
this.clearSession();
111+
return null;
112+
}
113+
114+
this.refreshInFlight = this._doRefresh();
115+
const result = await this.refreshInFlight;
116+
this.refreshInFlight = null;
117+
return result;
118+
}
119+
120+
private async _doRefresh(): Promise<AuthTokens | null> {
105121
if (!this.refreshToken) {
106122
this.clearSession();
107123
return null;

swift/Sources/Authon/SessionManager.swift

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ final class SessionManager {
1818
private var refreshRetryCount = 0
1919
private static let maxRefreshRetries = 3
2020
private static let retryDelays: [TimeInterval] = [3, 10, 30]
21+
private var refreshInFlight: Task<TokenPair?, Never>?
2122
private let onExpired: () -> Void
2223

2324
init(api: AuthonAPI, onRefreshed: @escaping (TokenPair, AuthonUser) -> Void, onExpired: @escaping () -> Void) {
@@ -115,27 +116,38 @@ final class SessionManager {
115116
}
116117

117118
func refresh() async -> TokenPair? {
119+
// Single-flight: if refresh is already in progress, wait for that result
120+
if let existing = refreshInFlight {
121+
return await existing.value
122+
}
118123
guard let refreshToken = tokens?.refreshToken, !refreshToken.isEmpty else { return nil }
119-
do {
120-
struct RefreshBody: Encodable {
121-
let refreshToken: String
124+
let task = Task<TokenPair?, Never> { [weak self] in
125+
guard let self else { return nil }
126+
do {
127+
struct RefreshBody: Encodable {
128+
let refreshToken: String
129+
}
130+
let response: ApiAuthResponse = try await self.api.request(
131+
"POST",
132+
"/v1/auth/token/refresh",
133+
body: RefreshBody(refreshToken: refreshToken)
134+
)
135+
let newPair = TokenPair(
136+
accessToken: response.accessToken,
137+
refreshToken: response.refreshToken,
138+
expiresAt: Date().timeIntervalSince1970 * 1000 + Double(response.expiresIn) * 1000
139+
)
140+
self.tokens = newPair
141+
self.saveToKeychain(newPair)
142+
return newPair
143+
} catch {
144+
return nil
122145
}
123-
let response: ApiAuthResponse = try await api.request(
124-
"POST",
125-
"/v1/auth/token/refresh",
126-
body: RefreshBody(refreshToken: refreshToken)
127-
)
128-
let newPair = TokenPair(
129-
accessToken: response.accessToken,
130-
refreshToken: response.refreshToken,
131-
expiresAt: Date().timeIntervalSince1970 * 1000 + Double(response.expiresIn) * 1000
132-
)
133-
tokens = newPair
134-
saveToKeychain(newPair)
135-
return newPair
136-
} catch {
137-
return nil
138146
}
147+
refreshInFlight = task
148+
let result = await task.value
149+
refreshInFlight = nil
150+
return result
139151
}
140152

141153
// MARK: - Auto-Refresh
@@ -166,6 +178,9 @@ final class SessionManager {
166178
}
167179

168180
private func performRefresh() {
181+
// Skip if refresh is already in flight
182+
if refreshInFlight != nil { return }
183+
169184
guard let refreshToken = tokens?.refreshToken else {
170185
onExpired()
171186
return

0 commit comments

Comments
 (0)