Skip to content

Commit 122244e

Browse files
committed
Improve OAuth handling
1 parent d53c066 commit 122244e

File tree

3 files changed

+151
-48
lines changed

3 files changed

+151
-48
lines changed

apps/array/src/main/services/oauth/schemas.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,19 @@ import { z } from "zod";
33
export const cloudRegion = z.enum(["us", "eu", "dev"]);
44
export type CloudRegion = z.infer<typeof cloudRegion>;
55

6+
/**
7+
* Error codes for OAuth operations.
8+
* - network_error: Transient network issue, should retry
9+
* - auth_error: Authentication failed (invalid token, 401/403), should logout
10+
* - unknown_error: Other errors
11+
*/
12+
export const oAuthErrorCode = z.enum([
13+
"network_error",
14+
"auth_error",
15+
"unknown_error",
16+
]);
17+
export type OAuthErrorCode = z.infer<typeof oAuthErrorCode>;
18+
619
export const oAuthTokenResponse = z.object({
720
access_token: z.string(),
821
expires_in: z.number(),
@@ -23,6 +36,7 @@ export const startFlowOutput = z.object({
2336
success: z.boolean(),
2437
data: oAuthTokenResponse.optional(),
2538
error: z.string().optional(),
39+
errorCode: oAuthErrorCode.optional(),
2640
});
2741
export type StartFlowOutput = z.infer<typeof startFlowOutput>;
2842

@@ -36,6 +50,7 @@ export const refreshTokenOutput = z.object({
3650
success: z.boolean(),
3751
data: oAuthTokenResponse.optional(),
3852
error: z.string().optional(),
53+
errorCode: oAuthErrorCode.optional(),
3954
});
4055
export type RefreshTokenOutput = z.infer<typeof refreshTokenOutput>;
4156

apps/array/src/main/services/oauth/service.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,13 @@ export class OAuthService {
178178
});
179179

180180
if (!response.ok) {
181-
throw new Error(`Token refresh failed: ${response.statusText}`);
181+
// 401/403 are auth errors - the token is invalid
182+
const isAuthError = response.status === 401 || response.status === 403;
183+
return {
184+
success: false,
185+
error: `Token refresh failed: ${response.statusText}`,
186+
errorCode: isAuthError ? "auth_error" : "unknown_error",
187+
};
182188
}
183189

184190
const tokenResponse: OAuthTokenResponse = await response.json();
@@ -187,10 +193,11 @@ export class OAuthService {
187193
success: true,
188194
data: tokenResponse,
189195
};
190-
} catch (error) {
196+
} catch {
191197
return {
192198
success: false,
193-
error: error instanceof Error ? error.message : "Unknown error",
199+
error: "Network error",
200+
errorCode: "network_error",
194201
};
195202
}
196203
}

apps/array/src/renderer/features/auth/stores/authStore.ts

Lines changed: 126 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ import { ANALYTICS_EVENTS } from "@/types/analytics";
1616

1717
const log = logger.scope("auth-store");
1818

19+
let refreshPromise: Promise<void> | null = null;
20+
21+
const REFRESH_MAX_RETRIES = 3;
22+
const REFRESH_INITIAL_DELAY_MS = 1000;
23+
1924
interface StoredTokens {
2025
accessToken: string;
2126
refreshToken: string;
@@ -152,62 +157,119 @@ export const useAuthStore = create<AuthState>()(
152157
},
153158

154159
refreshAccessToken: async () => {
155-
const state = get();
156-
157-
if (!state.oauthRefreshToken || !state.cloudRegion) {
158-
throw new Error("No refresh token available");
160+
// If a refresh is already in progress, wait for it
161+
if (refreshPromise) {
162+
log.debug("Token refresh already in progress, waiting...");
163+
return refreshPromise;
159164
}
160165

161-
const result = await trpcVanilla.oauth.refreshToken.mutate({
162-
refreshToken: state.oauthRefreshToken,
163-
region: state.cloudRegion,
164-
});
166+
const doRefresh = async () => {
167+
const state = get();
165168

166-
if (!result.success || !result.data) {
167-
// Refresh failed - logout user
168-
get().logout();
169-
throw new Error(result.error || "Token refresh failed");
170-
}
169+
if (!state.oauthRefreshToken || !state.cloudRegion) {
170+
throw new Error("No refresh token available");
171+
}
171172

172-
const tokenResponse = result.data;
173-
const expiresAt = Date.now() + tokenResponse.expires_in * 1000;
173+
// Retry with exponential backoff
174+
let lastError: Error | null = null;
175+
for (let attempt = 0; attempt < REFRESH_MAX_RETRIES; attempt++) {
176+
try {
177+
if (attempt > 0) {
178+
const delay = REFRESH_INITIAL_DELAY_MS * 2 ** (attempt - 1);
179+
log.debug(
180+
`Retrying token refresh (attempt ${attempt + 1}/${REFRESH_MAX_RETRIES}) after ${delay}ms`,
181+
);
182+
await new Promise((resolve) => setTimeout(resolve, delay));
183+
}
174184

175-
const storedTokens: StoredTokens = {
176-
accessToken: tokenResponse.access_token,
177-
refreshToken: tokenResponse.refresh_token,
178-
expiresAt,
179-
cloudRegion: state.cloudRegion,
180-
scopedTeams: tokenResponse.scoped_teams,
181-
};
185+
const result = await trpcVanilla.oauth.refreshToken.mutate({
186+
refreshToken: state.oauthRefreshToken,
187+
region: state.cloudRegion,
188+
});
182189

183-
const apiHost = getCloudUrlFromRegion(state.cloudRegion);
184-
const projectId =
185-
tokenResponse.scoped_teams?.[0] || state.projectId || undefined;
190+
if (!result.success || !result.data) {
191+
// Network errors should retry, auth errors should logout immediately
192+
if (result.errorCode === "network_error") {
193+
log.warn(
194+
`Token refresh network error (attempt ${attempt + 1}): ${result.error}`,
195+
);
196+
continue; // Retry
197+
}
198+
199+
// Auth error or unknown - logout
200+
get().logout();
201+
throw new Error(result.error || "Token refresh failed");
202+
}
186203

187-
const client = new PostHogAPIClient(
188-
tokenResponse.access_token,
189-
apiHost,
190-
async () => {
191-
await get().refreshAccessToken();
192-
const token = get().oauthAccessToken;
193-
if (!token) {
194-
throw new Error("No access token after refresh");
204+
const tokenResponse = result.data;
205+
const expiresAt = Date.now() + tokenResponse.expires_in * 1000;
206+
207+
const storedTokens: StoredTokens = {
208+
accessToken: tokenResponse.access_token,
209+
refreshToken: tokenResponse.refresh_token,
210+
expiresAt,
211+
cloudRegion: state.cloudRegion,
212+
scopedTeams: tokenResponse.scoped_teams,
213+
};
214+
215+
const apiHost = getCloudUrlFromRegion(state.cloudRegion);
216+
const projectId =
217+
tokenResponse.scoped_teams?.[0] ||
218+
state.projectId ||
219+
undefined;
220+
221+
const client = new PostHogAPIClient(
222+
tokenResponse.access_token,
223+
apiHost,
224+
async () => {
225+
await get().refreshAccessToken();
226+
const token = get().oauthAccessToken;
227+
if (!token) {
228+
throw new Error("No access token after refresh");
229+
}
230+
return token;
231+
},
232+
projectId,
233+
);
234+
235+
set({
236+
oauthAccessToken: tokenResponse.access_token,
237+
oauthRefreshToken: tokenResponse.refresh_token,
238+
tokenExpiry: expiresAt,
239+
storedTokens,
240+
client,
241+
...(projectId && { projectId }),
242+
});
243+
244+
get().scheduleTokenRefresh();
245+
return; // Success
246+
} catch (error) {
247+
lastError =
248+
error instanceof Error ? error : new Error(String(error));
249+
250+
// Check if this is a permanent failure (logout already called)
251+
if (!get().oauthRefreshToken) {
252+
throw lastError;
253+
}
254+
255+
// tRPC exceptions are typically IPC failures - retry them
256+
log.warn(
257+
`Token refresh exception (attempt ${attempt + 1}): ${lastError.message}`,
258+
);
195259
}
196-
return token;
197-
},
198-
projectId,
199-
);
260+
}
200261

201-
set({
202-
oauthAccessToken: tokenResponse.access_token,
203-
oauthRefreshToken: tokenResponse.refresh_token,
204-
tokenExpiry: expiresAt,
205-
storedTokens,
206-
client,
207-
...(projectId && { projectId }),
262+
// All retries exhausted
263+
log.error("Token refresh failed after all retries");
264+
get().logout();
265+
throw lastError || new Error("Token refresh failed");
266+
};
267+
268+
refreshPromise = doRefresh().finally(() => {
269+
refreshPromise = null;
208270
});
209271

210-
get().scheduleTokenRefresh();
272+
return refreshPromise;
211273
},
212274

213275
scheduleTokenRefresh: () => {
@@ -336,6 +398,25 @@ export const useAuthStore = create<AuthState>()(
336398
return true;
337399
} catch (error) {
338400
log.error("Failed to validate OAuth session:", error);
401+
402+
// Network errors from fetch are TypeError, wrapped by fetcher.ts as cause
403+
const isNetworkError =
404+
error instanceof Error && error.cause instanceof TypeError;
405+
406+
if (isNetworkError) {
407+
log.warn(
408+
"Network error during session validation - keeping session active",
409+
);
410+
set({
411+
isAuthenticated: true,
412+
client,
413+
projectId,
414+
});
415+
get().scheduleTokenRefresh();
416+
return true;
417+
}
418+
419+
// For auth errors (401/403) or unknown errors, clear the session
339420
set({ storedTokens: null, isAuthenticated: false });
340421
return false;
341422
}

0 commit comments

Comments
 (0)