diff --git a/apps/array/src/main/services/oauth/schemas.ts b/apps/array/src/main/services/oauth/schemas.ts index 45d43506..474c84c3 100644 --- a/apps/array/src/main/services/oauth/schemas.ts +++ b/apps/array/src/main/services/oauth/schemas.ts @@ -3,6 +3,19 @@ import { z } from "zod"; export const cloudRegion = z.enum(["us", "eu", "dev"]); export type CloudRegion = z.infer; +/** + * Error codes for OAuth operations. + * - network_error: Transient network issue, should retry + * - auth_error: Authentication failed (invalid token, 401/403), should logout + * - unknown_error: Other errors + */ +export const oAuthErrorCode = z.enum([ + "network_error", + "auth_error", + "unknown_error", +]); +export type OAuthErrorCode = z.infer; + export const oAuthTokenResponse = z.object({ access_token: z.string(), expires_in: z.number(), @@ -23,6 +36,7 @@ export const startFlowOutput = z.object({ success: z.boolean(), data: oAuthTokenResponse.optional(), error: z.string().optional(), + errorCode: oAuthErrorCode.optional(), }); export type StartFlowOutput = z.infer; @@ -36,6 +50,7 @@ export const refreshTokenOutput = z.object({ success: z.boolean(), data: oAuthTokenResponse.optional(), error: z.string().optional(), + errorCode: oAuthErrorCode.optional(), }); export type RefreshTokenOutput = z.infer; diff --git a/apps/array/src/main/services/oauth/service.ts b/apps/array/src/main/services/oauth/service.ts index 22425ffd..d22de7f6 100644 --- a/apps/array/src/main/services/oauth/service.ts +++ b/apps/array/src/main/services/oauth/service.ts @@ -178,7 +178,13 @@ export class OAuthService { }); if (!response.ok) { - throw new Error(`Token refresh failed: ${response.statusText}`); + // 401/403 are auth errors - the token is invalid + const isAuthError = response.status === 401 || response.status === 403; + return { + success: false, + error: `Token refresh failed: ${response.statusText}`, + errorCode: isAuthError ? "auth_error" : "unknown_error", + }; } const tokenResponse: OAuthTokenResponse = await response.json(); @@ -187,10 +193,11 @@ export class OAuthService { success: true, data: tokenResponse, }; - } catch (error) { + } catch { return { success: false, - error: error instanceof Error ? error.message : "Unknown error", + error: "Network error", + errorCode: "network_error", }; } } diff --git a/apps/array/src/renderer/features/auth/stores/authStore.ts b/apps/array/src/renderer/features/auth/stores/authStore.ts index fd157c02..987167a9 100644 --- a/apps/array/src/renderer/features/auth/stores/authStore.ts +++ b/apps/array/src/renderer/features/auth/stores/authStore.ts @@ -16,6 +16,12 @@ import { ANALYTICS_EVENTS } from "@/types/analytics"; const log = logger.scope("auth-store"); +let refreshPromise: Promise | null = null; +let initializePromise: Promise | null = null; + +const REFRESH_MAX_RETRIES = 3; +const REFRESH_INITIAL_DELAY_MS = 1000; + interface StoredTokens { accessToken: string; refreshToken: string; @@ -152,62 +158,119 @@ export const useAuthStore = create()( }, refreshAccessToken: async () => { - const state = get(); - - if (!state.oauthRefreshToken || !state.cloudRegion) { - throw new Error("No refresh token available"); + // If a refresh is already in progress, wait for it + if (refreshPromise) { + log.debug("Token refresh already in progress, waiting..."); + return refreshPromise; } - const result = await trpcVanilla.oauth.refreshToken.mutate({ - refreshToken: state.oauthRefreshToken, - region: state.cloudRegion, - }); + const doRefresh = async () => { + const state = get(); - if (!result.success || !result.data) { - // Refresh failed - logout user - get().logout(); - throw new Error(result.error || "Token refresh failed"); - } + if (!state.oauthRefreshToken || !state.cloudRegion) { + throw new Error("No refresh token available"); + } - const tokenResponse = result.data; - const expiresAt = Date.now() + tokenResponse.expires_in * 1000; + // Retry with exponential backoff + let lastError: Error | null = null; + for (let attempt = 0; attempt < REFRESH_MAX_RETRIES; attempt++) { + try { + if (attempt > 0) { + const delay = REFRESH_INITIAL_DELAY_MS * 2 ** (attempt - 1); + log.debug( + `Retrying token refresh (attempt ${attempt + 1}/${REFRESH_MAX_RETRIES}) after ${delay}ms`, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } - const storedTokens: StoredTokens = { - accessToken: tokenResponse.access_token, - refreshToken: tokenResponse.refresh_token, - expiresAt, - cloudRegion: state.cloudRegion, - scopedTeams: tokenResponse.scoped_teams, - }; + const result = await trpcVanilla.oauth.refreshToken.mutate({ + refreshToken: state.oauthRefreshToken, + region: state.cloudRegion, + }); - const apiHost = getCloudUrlFromRegion(state.cloudRegion); - const projectId = - tokenResponse.scoped_teams?.[0] || state.projectId || undefined; + if (!result.success || !result.data) { + // Network errors should retry, auth errors should logout immediately + if (result.errorCode === "network_error") { + log.warn( + `Token refresh network error (attempt ${attempt + 1}): ${result.error}`, + ); + continue; // Retry + } + + // Auth error or unknown - logout + get().logout(); + throw new Error(result.error || "Token refresh failed"); + } - const client = new PostHogAPIClient( - tokenResponse.access_token, - apiHost, - async () => { - await get().refreshAccessToken(); - const token = get().oauthAccessToken; - if (!token) { - throw new Error("No access token after refresh"); + const tokenResponse = result.data; + const expiresAt = Date.now() + tokenResponse.expires_in * 1000; + + const storedTokens: StoredTokens = { + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + expiresAt, + cloudRegion: state.cloudRegion, + scopedTeams: tokenResponse.scoped_teams, + }; + + const apiHost = getCloudUrlFromRegion(state.cloudRegion); + const projectId = + tokenResponse.scoped_teams?.[0] || + state.projectId || + undefined; + + const client = new PostHogAPIClient( + tokenResponse.access_token, + apiHost, + async () => { + await get().refreshAccessToken(); + const token = get().oauthAccessToken; + if (!token) { + throw new Error("No access token after refresh"); + } + return token; + }, + projectId, + ); + + set({ + oauthAccessToken: tokenResponse.access_token, + oauthRefreshToken: tokenResponse.refresh_token, + tokenExpiry: expiresAt, + storedTokens, + client, + ...(projectId && { projectId }), + }); + + get().scheduleTokenRefresh(); + return; // Success + } catch (error) { + lastError = + error instanceof Error ? error : new Error(String(error)); + + // Check if this is a permanent failure (logout already called) + if (!get().oauthRefreshToken) { + throw lastError; + } + + // tRPC exceptions are typically IPC failures - retry them + log.warn( + `Token refresh exception (attempt ${attempt + 1}): ${lastError.message}`, + ); } - return token; - }, - projectId, - ); + } - set({ - oauthAccessToken: tokenResponse.access_token, - oauthRefreshToken: tokenResponse.refresh_token, - tokenExpiry: expiresAt, - storedTokens, - client, - ...(projectId && { projectId }), + // All retries exhausted + log.error("Token refresh failed after all retries"); + get().logout(); + throw lastError || new Error("Token refresh failed"); + }; + + refreshPromise = doRefresh().finally(() => { + refreshPromise = null; }); - get().scheduleTokenRefresh(); + return refreshPromise; }, scheduleTokenRefresh: () => { @@ -243,105 +306,138 @@ export const useAuthStore = create()( }, initializeOAuth: async () => { - // Wait for zustand hydration from async storage - if (!useAuthStore.persist.hasHydrated()) { - await new Promise((resolve) => { - useAuthStore.persist.onFinishHydration(() => resolve()); - }); + // If initialization is already in progress, wait for it + if (initializePromise) { + log.debug("OAuth initialization already in progress, waiting..."); + return initializePromise; } - const state = get(); - - if (state.storedTokens) { - const tokens = state.storedTokens; - const now = Date.now(); - const isExpired = tokens.expiresAt <= now; - - set({ - oauthAccessToken: tokens.accessToken, - oauthRefreshToken: tokens.refreshToken, - tokenExpiry: tokens.expiresAt, - cloudRegion: tokens.cloudRegion, - }); - - if (isExpired) { - try { - await get().refreshAccessToken(); - } catch (error) { - log.error("Failed to refresh expired token:", error); - set({ storedTokens: null, isAuthenticated: false }); - return false; - } + const doInitialize = async (): Promise => { + // Wait for zustand hydration from async storage + if (!useAuthStore.persist.hasHydrated()) { + await new Promise((resolve) => { + useAuthStore.persist.onFinishHydration(() => resolve()); + }); } - // Re-fetch tokens after potential refresh to get updated values - const currentTokens = get().storedTokens; - if (!currentTokens) { - return false; - } + const state = get(); - const apiHost = getCloudUrlFromRegion(currentTokens.cloudRegion); - const projectId = currentTokens.scopedTeams?.[0]; + if (state.storedTokens) { + const tokens = state.storedTokens; + const now = Date.now(); + const isExpired = tokens.expiresAt <= now; - if (!projectId) { - log.error("No project ID found in stored tokens"); - get().logout(); - return false; - } + set({ + oauthAccessToken: tokens.accessToken, + oauthRefreshToken: tokens.refreshToken, + tokenExpiry: tokens.expiresAt, + cloudRegion: tokens.cloudRegion, + }); - const client = new PostHogAPIClient( - currentTokens.accessToken, - apiHost, - async () => { - await get().refreshAccessToken(); - const token = get().oauthAccessToken; - if (!token) { - throw new Error("No access token after refresh"); + if (isExpired) { + try { + await get().refreshAccessToken(); + } catch (error) { + log.error("Failed to refresh expired token:", error); + set({ storedTokens: null, isAuthenticated: false }); + return false; } - return token; - }, - projectId, - ); + } + + // Re-fetch tokens after potential refresh to get updated values + const currentTokens = get().storedTokens; + if (!currentTokens) { + return false; + } - try { - const user = await client.getCurrentUser(); + const apiHost = getCloudUrlFromRegion(currentTokens.cloudRegion); + const projectId = currentTokens.scopedTeams?.[0]; - set({ - isAuthenticated: true, - client, + if (!projectId) { + log.error("No project ID found in stored tokens"); + get().logout(); + return false; + } + + const client = new PostHogAPIClient( + currentTokens.accessToken, + apiHost, + async () => { + await get().refreshAccessToken(); + const token = get().oauthAccessToken; + if (!token) { + throw new Error("No access token after refresh"); + } + return token; + }, projectId, - }); + ); - get().scheduleTokenRefresh(); + try { + const user = await client.getCurrentUser(); - // Use distinct_id to match web sessions (same as PostHog web app) - const distinctId = user.distinct_id || user.email; - identifyUser(distinctId, { - email: user.email, - uuid: user.uuid, - project_id: projectId.toString(), - region: tokens.cloudRegion, - }); + set({ + isAuthenticated: true, + client, + projectId, + }); + + get().scheduleTokenRefresh(); - trpcVanilla.analytics.setUserId.mutate({ - userId: distinctId, - properties: { + // Use distinct_id to match web sessions (same as PostHog web app) + const distinctId = user.distinct_id || user.email; + identifyUser(distinctId, { email: user.email, uuid: user.uuid, project_id: projectId.toString(), region: tokens.cloudRegion, - }, - }); + }); + + trpcVanilla.analytics.setUserId.mutate({ + userId: distinctId, + properties: { + email: user.email, + uuid: user.uuid, + project_id: projectId.toString(), + region: tokens.cloudRegion, + }, + }); + + return true; + } catch (error) { + log.error("Failed to validate OAuth session:", error); + + // Network errors from fetch are TypeError, wrapped by fetcher.ts as cause + const isNetworkError = + error instanceof Error && error.cause instanceof TypeError; + + if (isNetworkError) { + log.warn( + "Network error during session validation - keeping session active", + ); + set({ + isAuthenticated: true, + client, + projectId, + }); + get().scheduleTokenRefresh(); + return true; + } - return true; - } catch (error) { - log.error("Failed to validate OAuth session:", error); - set({ storedTokens: null, isAuthenticated: false }); - return false; + // For auth errors (401/403) or unknown errors, clear the session + set({ storedTokens: null, isAuthenticated: false }); + return false; + } } - } - return state.isAuthenticated; + return state.isAuthenticated; + }; + + initializePromise = doInitialize().finally(() => { + initializePromise = null; + }); + + return initializePromise; }, logout: () => {