Skip to content

Commit 96116f8

Browse files
committed
fix(swift): remove .convertToSnakeCase encoder + fix 7 session retry bugs across all SDKs
Critical: AuthonAPI encoder was converting camelCase keys to snake_case (refreshToken → refresh_token), causing token refresh and all multi-word body fields (displayName, mfaToken, walletType, etc.) to silently fail. SessionManager rewritten: - Replace isPerformingRefresh + recursive calls with retryTask + while loop - Add sessionGeneration counter to invalidate stale retry chains on new login - setTokens() cancels retryTask, resets retryCount, bumps generation - scheduleRefresh() cancels retryTask alongside refreshWorkItem - Task.sleep CancellationError properly caught (cancelled retry exits) - onExpired() checks sessionGeneration before killing session - destroy() cancels retryTask and bumps generation JS SDK: setSession() now resets refreshRetryCount RN SDK: added 3x retry with backoff (was missing entirely), scheduleRefresh resets retryCount
1 parent 749a197 commit 96116f8

9 files changed

Lines changed: 72 additions & 25 deletions

File tree

packages/js/dist/index.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2555,6 +2555,7 @@ var SessionManager = class _SessionManager {
25552555
this.accessToken = tokens.accessToken;
25562556
this.refreshToken = tokens.refreshToken;
25572557
this.user = tokens.user;
2558+
this.refreshRetryCount = 0;
25582559
this.persistToStorage();
25592560
if (tokens.expiresIn && tokens.expiresIn > 0) {
25602561
this.scheduleRefresh(tokens.expiresIn);

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: 1 addition & 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/src/session.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export class SessionManager {
7575
this.accessToken = tokens.accessToken;
7676
this.refreshToken = tokens.refreshToken;
7777
this.user = tokens.user;
78+
this.refreshRetryCount = 0;
7879
this.persistToStorage();
7980
if (tokens.expiresIn && tokens.expiresIn > 0) {
8081
this.scheduleRefresh(tokens.expiresIn);

packages/react-native/src/client.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ export class AuthonMobileClient {
4949
private storage: TokenStorage | null = null;
5050
private refreshInFlight: Promise<TokenPair | null> | null = null;
5151
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
52+
private refreshRetryCount = 0;
53+
private static readonly MAX_REFRESH_RETRIES = 3;
54+
private static readonly RETRY_DELAYS = [3, 10, 30]; // seconds
5255
private listeners: Map<string, Set<(...args: unknown[]) => void>> = new Map();
5356

5457
// Cached provider/branding data
@@ -206,7 +209,6 @@ export class AuthonMobileClient {
206209
}
207210

208211
private async _doRefresh(token: string): Promise<TokenPair | null> {
209-
210212
try {
211213
const res = await fetch(`${this.apiUrl}/v1/auth/token/refresh`, {
212214
method: 'POST',
@@ -220,25 +222,48 @@ export class AuthonMobileClient {
220222
if (!res.ok) {
221223
// 401 = refresh token permanently invalid
222224
if (res.status === 401) {
225+
this.refreshRetryCount = 0;
223226
this.clearSession();
227+
this.emit('signedOut');
228+
return null;
224229
}
225-
// Other errors (500, network) — preserve tokens for retry
230+
// Other errors (500, network) — retry with backoff
231+
this.retryRefresh();
226232
return null;
227233
}
228234

229235
const data = (await res.json()) as ApiAuthResponse;
236+
this.refreshRetryCount = 0;
230237
this.tokens = this.toTokenPair(data);
231238
this.user = data.user;
232239
await this.persistTokens();
233240
this.scheduleRefresh(this.tokens.expiresAt);
234241
this.emit('tokenRefreshed');
235242
return this.tokens;
236243
} catch {
237-
// Network error — do NOT clear session
244+
// Network error — retry with backoff, do NOT clear session
245+
this.retryRefresh();
238246
return null;
239247
}
240248
}
241249

250+
private retryRefresh(): void {
251+
if (this.refreshRetryCount < AuthonMobileClient.MAX_REFRESH_RETRIES) {
252+
const delay = AuthonMobileClient.RETRY_DELAYS[
253+
Math.min(this.refreshRetryCount, AuthonMobileClient.RETRY_DELAYS.length - 1)
254+
];
255+
this.refreshRetryCount++;
256+
this.clearRefreshTimer();
257+
this.refreshTimer = setTimeout(() => {
258+
this.refreshToken().catch(() => {});
259+
}, delay * 1000);
260+
} else {
261+
this.refreshRetryCount = 0;
262+
this.clearSession();
263+
this.emit('signedOut');
264+
}
265+
}
266+
242267
getAccessToken(): string | null {
243268
return this.tokens?.accessToken || null;
244269
}
@@ -501,6 +526,7 @@ export class AuthonMobileClient {
501526
/** Schedule auto-refresh 60 seconds before token expiry (like JS SDK) */
502527
private scheduleRefresh(expiresAt: number): void {
503528
this.clearRefreshTimer();
529+
this.refreshRetryCount = 0;
504530
const refreshIn = Math.max(expiresAt - Date.now() - 60_000, 30_000);
505531
this.refreshTimer = setTimeout(() => {
506532
this.refreshToken().catch(() => {});

swift/Sources/Authon/Authon.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -647,7 +647,6 @@ public final class Authon: ObservableObject {
647647

648648
if let body {
649649
let encoder = JSONEncoder()
650-
encoder.keyEncodingStrategy = .convertToSnakeCase
651650
req.httpBody = try encoder.encode(AnyEncodableBox(body))
652651
}
653652

swift/Sources/Authon/AuthonAPI.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ final class AuthonAPI: Sendable {
1515
dec.keyDecodingStrategy = .convertFromSnakeCase
1616
self.decoder = dec
1717
let enc = JSONEncoder()
18-
enc.keyEncodingStrategy = .convertToSnakeCase
1918
self.encoder = enc
2019
}
2120

swift/Sources/Authon/SessionManager.swift

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ final class SessionManager {
1919
private static let maxRefreshRetries = 3
2020
private static let retryDelays: [TimeInterval] = [3, 10, 30]
2121
private var refreshInFlight: Task<RefreshResult?, Never>?
22-
private var isPerformingRefresh = false
22+
private var retryTask: Task<Void, Never>?
2323
private let onExpired: () -> Void
24+
private var sessionGeneration: UInt64 = 0
2425

2526
init(publishableKey: String, api: AuthonAPI, onRefreshed: @escaping (TokenPair, AuthonUser) -> Void, onExpired: @escaping () -> Void) {
2627
self.keychainService = "dev.authon.sdk"
@@ -95,6 +96,10 @@ final class SessionManager {
9596
// MARK: - Token Management
9697

9798
func setTokens(_ pair: TokenPair) {
99+
retryTask?.cancel()
100+
retryTask = nil
101+
refreshRetryCount = 0
102+
sessionGeneration &+= 1
98103
tokens = pair
99104
saveToKeychain(pair)
100105
scheduleRefresh()
@@ -160,6 +165,8 @@ final class SessionManager {
160165
func scheduleRefresh() {
161166
refreshWorkItem?.cancel()
162167
refreshWorkItem = nil
168+
retryTask?.cancel()
169+
retryTask = nil
163170

164171
guard let tokens else { return }
165172

@@ -183,29 +190,39 @@ final class SessionManager {
183190
}
184191

185192
private func performRefresh() {
186-
guard !isPerformingRefresh else { return }
187-
isPerformingRefresh = true
193+
retryTask?.cancel()
194+
let generation = sessionGeneration
188195

189-
Task { [weak self] in
196+
retryTask = Task { [weak self] in
190197
guard let self else { return }
191-
defer { self.isPerformingRefresh = false }
192-
193-
if let result = await self.refresh() {
194-
self.refreshRetryCount = 0
195-
self.scheduleRefresh()
196-
self.onRefreshed(result.pair, result.user)
197-
} else {
198-
if self.refreshRetryCount < Self.maxRefreshRetries {
199-
let delay = Self.retryDelays[min(self.refreshRetryCount, Self.retryDelays.count - 1)]
200-
self.refreshRetryCount += 1
201-
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
202-
self.isPerformingRefresh = false
203-
self.performRefresh()
204-
} else {
198+
199+
while !Task.isCancelled, self.sessionGeneration == generation {
200+
if let result = await self.refresh() {
201+
guard !Task.isCancelled, self.sessionGeneration == generation else { return }
202+
self.refreshRetryCount = 0
203+
self.scheduleRefresh()
204+
self.onRefreshed(result.pair, result.user)
205+
return
206+
}
207+
208+
guard !Task.isCancelled, self.sessionGeneration == generation else { return }
209+
210+
if self.refreshRetryCount >= Self.maxRefreshRetries {
205211
self.refreshRetryCount = 0
206212
self.refreshWorkItem?.cancel()
207213
self.refreshWorkItem = nil
214+
guard self.sessionGeneration == generation else { return }
208215
self.onExpired()
216+
return
217+
}
218+
219+
let delay = Self.retryDelays[min(self.refreshRetryCount, Self.retryDelays.count - 1)]
220+
self.refreshRetryCount += 1
221+
222+
do {
223+
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
224+
} catch {
225+
return
209226
}
210227
}
211228
}
@@ -258,6 +275,9 @@ final class SessionManager {
258275
func destroy() {
259276
refreshWorkItem?.cancel()
260277
refreshWorkItem = nil
278+
retryTask?.cancel()
279+
retryTask = nil
280+
sessionGeneration &+= 1
261281
tokens = nil
262282
NotificationCenter.default.removeObserver(self)
263283
}

0 commit comments

Comments
 (0)