diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index d881aafa7..c3ac22a49 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -1,6 +1,6 @@ import "reflect-metadata"; import os from "node:os"; -import { app, powerMonitor } from "electron"; +import { app } from "electron"; import log from "electron-log/main"; import "./utils/logger"; import "./services/index.js"; @@ -93,14 +93,6 @@ app.whenReady().then(async () => { createWindow(); await initializeServices(); initializeDeepLinks(); - await initializeServices(); - powerMonitor.on("suspend", () => { - log.info("System entering sleep"); - }); - - powerMonitor.on("resume", () => { - log.info("System waking from sleep"); - }); }); app.on("window-all-closed", () => { diff --git a/apps/code/src/main/services/auth/service.test.ts b/apps/code/src/main/services/auth/service.test.ts index 8caf9ac69..83d1cd7dd 100644 --- a/apps/code/src/main/services/auth/service.test.ts +++ b/apps/code/src/main/services/auth/service.test.ts @@ -1,12 +1,27 @@ +import { EventEmitter } from "node:events"; import { OAUTH_SCOPE_VERSION } from "@shared/constants/oauth"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createMockAuthPreferenceRepository } from "../../db/repositories/auth-preference-repository.mock"; import { createMockAuthSessionRepository } from "../../db/repositories/auth-session-repository.mock"; import { decrypt, encrypt } from "../../utils/encryption"; +import { ConnectivityEvent } from "../connectivity/schemas"; import type { ConnectivityService } from "../connectivity/service"; import type { OAuthService } from "../oauth/service"; import { AuthService } from "./service"; +const mockPowerMonitor = vi.hoisted(() => ({ + on: vi.fn(), + off: vi.fn(), +})); + +vi.mock("electron", () => ({ + powerMonitor: mockPowerMonitor, +})); + +vi.mock("@shared/utils/backoff", () => ({ + sleepWithBackoff: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("../../utils/logger.js", () => ({ logger: { scope: () => ({ @@ -18,36 +33,75 @@ vi.mock("../../utils/logger.js", () => ({ }, })); +function mockTokenResponse( + overrides: { + accessToken?: string; + refreshToken?: string; + scopedTeams?: number[]; + scopedOrgs?: string[]; + } = {}, +) { + return { + success: true as const, + data: { + access_token: overrides.accessToken ?? "access-token", + refresh_token: overrides.refreshToken ?? "refresh-token", + expires_in: 3600, + token_type: "Bearer", + scope: "", + scoped_teams: overrides.scopedTeams ?? [42], + scoped_organizations: overrides.scopedOrgs ?? ["org-1"], + }, + }; +} + describe("AuthService", () => { const preferenceRepository = createMockAuthPreferenceRepository(); const repository = createMockAuthSessionRepository(); + const oauthService = { refreshToken: vi.fn(), startFlow: vi.fn(), startSignupFlow: vi.fn(), } as unknown as OAuthService; - const connectivityService = { + + const connectivityEmitter = new EventEmitter(); + const connectivityService = Object.assign(connectivityEmitter, { getStatus: vi.fn(() => ({ isOnline: true })), - } as unknown as ConnectivityService; + checkNow: vi.fn(), + }) as unknown as ConnectivityService; let service: AuthService; - beforeEach(() => { - preferenceRepository._preferences = []; - repository.clearCurrent(); - vi.clearAllMocks(); - service = new AuthService( - preferenceRepository, - repository, - oauthService, - connectivityService, - ); - }); + function seedStoredSession( + overrides: { + refreshToken?: string; + selectedProjectId?: number | null; + scopeVersion?: number; + } = {}, + ) { + repository.saveCurrent({ + refreshTokenEncrypted: encrypt( + overrides.refreshToken ?? "stored-refresh-token", + ), + cloudRegion: "us", + selectedProjectId: overrides.selectedProjectId ?? null, + scopeVersion: overrides.scopeVersion ?? OAUTH_SCOPE_VERSION, + }); + } - afterEach(async () => { - vi.unstubAllGlobals(); - await service.logout(); - }); + function emitOnline() { + connectivityEmitter.emit(ConnectivityEvent.StatusChange, { + isOnline: true, + }); + } + + function getResumeHandler(): () => void { + const call = mockPowerMonitor.on.mock.calls.find( + (c: unknown[]) => c[0] === "resume", + ); + return call?.[1] as () => void; + } const stubAuthFetch = (accountKey = "user-1") => { vi.stubGlobal( @@ -70,6 +124,26 @@ describe("AuthService", () => { ); }; + beforeEach(() => { + preferenceRepository._preferences = []; + repository.clearCurrent(); + vi.clearAllMocks(); + connectivityEmitter.removeAllListeners(); + service = new AuthService( + preferenceRepository, + repository, + oauthService, + connectivityService, + ); + service.init(); + }); + + afterEach(async () => { + vi.unstubAllGlobals(); + service.shutdown(); + await service.logout(); + }); + it("bootstraps to anonymous when there is no stored session", async () => { await service.initialize(); @@ -86,9 +160,8 @@ describe("AuthService", () => { }); it("requires scope reauthentication when the stored scope version is stale", async () => { - repository.saveCurrent({ - refreshTokenEncrypted: encrypt("refresh-token"), - cloudRegion: "us", + seedStoredSession({ + refreshToken: "refresh-token", selectedProjectId: 123, scopeVersion: OAUTH_SCOPE_VERSION - 1, }); @@ -108,26 +181,14 @@ describe("AuthService", () => { }); it("restores an authenticated session by refreshing the stored refresh token", async () => { - repository.saveCurrent({ - refreshTokenEncrypted: encrypt("stored-refresh-token"), - cloudRegion: "us", - selectedProjectId: 42, - scopeVersion: OAUTH_SCOPE_VERSION, - }); - - vi.mocked(oauthService.refreshToken).mockResolvedValue({ - success: true, - data: { - access_token: "new-access-token", - refresh_token: "rotated-refresh-token", - expires_in: 3600, - token_type: "Bearer", - scope: "", - scoped_teams: [42, 84], - scoped_organizations: ["org-1"], - }, - }); - + seedStoredSession({ selectedProjectId: 42 }); + vi.mocked(oauthService.refreshToken).mockResolvedValue( + mockTokenResponse({ + accessToken: "new-access-token", + refreshToken: "rotated-refresh-token", + scopedTeams: [42, 84], + }), + ); stubAuthFetch(); await service.initialize(); @@ -143,42 +204,27 @@ describe("AuthService", () => { needsScopeReauth: false, }); - const persisted = repository.getCurrent(); - expect(persisted).not.toBeNull(); - expect(decrypt(persisted?.refreshTokenEncrypted ?? "")).toBe( + expect(decrypt(repository.getCurrent()?.refreshTokenEncrypted ?? "")).toBe( "rotated-refresh-token", ); }); it("forces a token refresh when explicitly requested", async () => { - vi.mocked(oauthService.startFlow).mockResolvedValue({ - success: true, - data: { - access_token: "initial-access-token", - refresh_token: "initial-refresh-token", - expires_in: 3600, - token_type: "Bearer", - scope: "", - scoped_teams: [42], - scoped_organizations: ["org-1"], - }, - }); - vi.mocked(oauthService.refreshToken).mockResolvedValue({ - success: true, - data: { - access_token: "refreshed-access-token", - refresh_token: "rotated-refresh-token", - expires_in: 3600, - token_type: "Bearer", - scope: "", - scoped_teams: [42], - scoped_organizations: ["org-1"], - }, - }); + vi.mocked(oauthService.startFlow).mockResolvedValue( + mockTokenResponse({ + accessToken: "initial-access-token", + refreshToken: "initial-refresh-token", + }), + ); + vi.mocked(oauthService.refreshToken).mockResolvedValue( + mockTokenResponse({ + accessToken: "refreshed-access-token", + refreshToken: "rotated-refresh-token", + }), + ); stubAuthFetch(); await service.login("us"); - const token = await service.refreshAccessToken(); expect(token.accessToken).toBe("refreshed-access-token"); @@ -193,43 +239,27 @@ describe("AuthService", () => { it("preserves the selected project across logout and re-login for the same account", async () => { vi.mocked(oauthService.startFlow) - .mockResolvedValueOnce({ - success: true, - data: { - access_token: "initial-access-token", - refresh_token: "initial-refresh-token", - expires_in: 3600, - token_type: "Bearer", - scope: "", - scoped_teams: [42, 84], - scoped_organizations: ["org-1"], - }, - }) - .mockResolvedValueOnce({ - success: true, - data: { - access_token: "second-access-token", - refresh_token: "second-refresh-token", - expires_in: 3600, - token_type: "Bearer", - scope: "", - scoped_teams: [42, 84], - scoped_organizations: ["org-1"], - }, - }); - vi.mocked(oauthService.refreshToken).mockResolvedValue({ - success: true, - data: { - access_token: "refreshed-access-token", - refresh_token: "refreshed-refresh-token", - expires_in: 3600, - token_type: "Bearer", - scope: "", - scoped_teams: [42, 84], - scoped_organizations: ["org-1"], - }, - }); - + .mockResolvedValueOnce( + mockTokenResponse({ + accessToken: "initial-access-token", + refreshToken: "initial-refresh-token", + scopedTeams: [42, 84], + }), + ) + .mockResolvedValueOnce( + mockTokenResponse({ + accessToken: "second-access-token", + refreshToken: "second-refresh-token", + scopedTeams: [42, 84], + }), + ); + vi.mocked(oauthService.refreshToken).mockResolvedValue( + mockTokenResponse({ + accessToken: "refreshed-access-token", + refreshToken: "refreshed-refresh-token", + scopedTeams: [42, 84], + }), + ); stubAuthFetch(); await service.login("us"); @@ -254,43 +284,27 @@ describe("AuthService", () => { it("restores the selected project after app restart while logged out", async () => { vi.mocked(oauthService.startFlow) - .mockResolvedValueOnce({ - success: true, - data: { - access_token: "initial-access-token", - refresh_token: "initial-refresh-token", - expires_in: 3600, - token_type: "Bearer", - scope: "", - scoped_teams: [42, 84], - scoped_organizations: ["org-1"], - }, - }) - .mockResolvedValueOnce({ - success: true, - data: { - access_token: "second-access-token", - refresh_token: "second-refresh-token", - expires_in: 3600, - token_type: "Bearer", - scope: "", - scoped_teams: [42, 84], - scoped_organizations: ["org-1"], - }, - }); - vi.mocked(oauthService.refreshToken).mockResolvedValue({ - success: true, - data: { - access_token: "refreshed-access-token", - refresh_token: "refreshed-refresh-token", - expires_in: 3600, - token_type: "Bearer", - scope: "", - scoped_teams: [42, 84], - scoped_organizations: ["org-1"], - }, - }); - + .mockResolvedValueOnce( + mockTokenResponse({ + accessToken: "initial-access-token", + refreshToken: "initial-refresh-token", + scopedTeams: [42, 84], + }), + ) + .mockResolvedValueOnce( + mockTokenResponse({ + accessToken: "second-access-token", + refreshToken: "second-refresh-token", + scopedTeams: [42, 84], + }), + ); + vi.mocked(oauthService.refreshToken).mockResolvedValue( + mockTokenResponse({ + accessToken: "refreshed-access-token", + refreshToken: "refreshed-refresh-token", + scopedTeams: [42, 84], + }), + ); stubAuthFetch(); await service.login("us"); @@ -313,4 +327,236 @@ describe("AuthService", () => { availableProjectIds: [42, 84], }); }); + + describe("lifecycle: connectivity recovery", () => { + it("recovers session when connectivity changes to online", async () => { + seedStoredSession({ selectedProjectId: 42 }); + vi.mocked(connectivityService.getStatus).mockReturnValue({ + isOnline: false, + }); + await service.initialize(); + expect(service.getState().status).toBe("anonymous"); + + vi.mocked(connectivityService.getStatus).mockReturnValue({ + isOnline: true, + }); + vi.mocked(oauthService.refreshToken).mockResolvedValue( + mockTokenResponse(), + ); + stubAuthFetch(); + + emitOnline(); + + await vi.waitFor(() => { + expect(service.getState().status).toBe("authenticated"); + }); + }); + + it("does nothing when session already exists", async () => { + vi.mocked(oauthService.startFlow).mockResolvedValue(mockTokenResponse()); + stubAuthFetch(); + await service.login("us"); + vi.mocked(oauthService.refreshToken).mockClear(); + + emitOnline(); + + await new Promise((r) => setTimeout(r, 10)); + expect(oauthService.refreshToken).not.toHaveBeenCalled(); + }); + + it("ignores offline events", async () => { + seedStoredSession(); + + connectivityEmitter.emit(ConnectivityEvent.StatusChange, { + isOnline: false, + }); + + await new Promise((r) => setTimeout(r, 10)); + expect(oauthService.refreshToken).not.toHaveBeenCalled(); + }); + + it("deduplicates concurrent recovery attempts", async () => { + seedStoredSession(); + + let resolveRefresh!: () => void; + vi.mocked(oauthService.refreshToken).mockReturnValue( + new Promise((resolve) => { + resolveRefresh = () => resolve(mockTokenResponse()); + }), + ); + stubAuthFetch(); + + emitOnline(); + emitOnline(); + + await new Promise((r) => setTimeout(r, 10)); + expect(oauthService.refreshToken).toHaveBeenCalledTimes(1); + + resolveRefresh(); + + await vi.waitFor(() => { + expect(service.getState().status).toBe("authenticated"); + }); + }); + }); + + describe("lifecycle: power monitor resume", () => { + it("registers and unregisters the resume handler", () => { + expect(mockPowerMonitor.on).toHaveBeenCalledWith( + "resume", + expect.any(Function), + ); + + service.shutdown(); + expect(mockPowerMonitor.off).toHaveBeenCalledWith( + "resume", + expect.any(Function), + ); + }); + + it("attempts session recovery on resume", async () => { + seedStoredSession(); + vi.mocked(oauthService.refreshToken).mockResolvedValue( + mockTokenResponse(), + ); + stubAuthFetch(); + + getResumeHandler()(); + + await vi.waitFor(() => { + expect(service.getState().status).toBe("authenticated"); + }); + }); + }); + + describe("refresh retry with error codes", () => { + it.each([ + { errorCode: "network_error" as const, label: "network_error" }, + { errorCode: "server_error" as const, label: "server_error" }, + ])( + "retries on $label and succeeds on second attempt", + async ({ errorCode }) => { + seedStoredSession(); + vi.mocked(oauthService.refreshToken) + .mockResolvedValueOnce({ + success: false, + error: "Transient failure", + errorCode, + }) + .mockResolvedValueOnce(mockTokenResponse()); + stubAuthFetch(); + + await service.initialize(); + + expect(service.getState().status).toBe("authenticated"); + expect(oauthService.refreshToken).toHaveBeenCalledTimes(2); + }, + ); + + it("does not retry on auth_error and forces logout", async () => { + seedStoredSession({ selectedProjectId: 42 }); + vi.mocked(oauthService.refreshToken).mockResolvedValue({ + success: false, + error: "Token revoked", + errorCode: "auth_error", + }); + + await service.initialize(); + + expect(service.getState()).toMatchObject({ + status: "anonymous", + cloudRegion: "us", + projectId: 42, + }); + expect(oauthService.refreshToken).toHaveBeenCalledTimes(1); + expect(repository.getCurrent()).toBeNull(); + }); + + it("does not retry on unknown_error", async () => { + seedStoredSession(); + vi.mocked(oauthService.refreshToken).mockResolvedValue({ + success: false, + error: "Something weird", + errorCode: "unknown_error", + }); + + await service.initialize(); + + expect(service.getState().status).toBe("anonymous"); + expect(oauthService.refreshToken).toHaveBeenCalledTimes(1); + }); + + it("gives up after all retry attempts are exhausted", async () => { + seedStoredSession(); + vi.mocked(oauthService.refreshToken).mockResolvedValue({ + success: false, + error: "Network error", + errorCode: "network_error", + }); + + await service.initialize(); + + expect(service.getState().status).toBe("anonymous"); + expect(oauthService.refreshToken).toHaveBeenCalledTimes(3); + }); + }); + + describe("redeemInviteCode uses authenticatedFetch", () => { + it("retries on 401 via authenticatedFetch", async () => { + vi.mocked(oauthService.startFlow).mockResolvedValue( + mockTokenResponse({ + accessToken: "initial-token", + refreshToken: "refresh-token", + }), + ); + vi.mocked(oauthService.refreshToken).mockResolvedValue( + mockTokenResponse({ + accessToken: "refreshed-token", + refreshToken: "new-refresh-token", + }), + ); + + let redeemCallCount = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | Request) => { + const url = typeof input === "string" ? input : input.url; + + if (url.includes("/api/users/@me/")) { + return { + ok: true, + json: vi.fn().mockResolvedValue({ uuid: "user-1" }), + } as unknown as Response; + } + + if (url.includes("/invites/redeem/")) { + redeemCallCount++; + if (redeemCallCount === 1) { + return { + ok: false, + status: 401, + json: () => Promise.resolve({}), + } as unknown as Response; + } + return { + ok: true, + status: 200, + json: () => Promise.resolve({ success: true }), + } as unknown as Response; + } + + return { + ok: true, + json: vi.fn().mockResolvedValue({ has_access: true }), + } as unknown as Response; + }) as typeof fetch, + ); + + await service.login("us"); + const state = await service.redeemInviteCode("test-code"); + + expect(state.hasCodeAccess).toBe(true); + expect(redeemCallCount).toBe(2); + }); + }); }); diff --git a/apps/code/src/main/services/auth/service.ts b/apps/code/src/main/services/auth/service.ts index 41cadfdf9..973899889 100644 --- a/apps/code/src/main/services/auth/service.ts +++ b/apps/code/src/main/services/auth/service.ts @@ -3,7 +3,9 @@ import { OAUTH_SCOPE_VERSION, } from "@shared/constants/oauth"; import type { CloudRegion } from "@shared/types/oauth"; -import { inject, injectable } from "inversify"; +import { type BackoffOptions, sleepWithBackoff } from "@shared/utils/backoff"; +import { powerMonitor } from "electron"; +import { inject, injectable, postConstruct, preDestroy } from "inversify"; import type { IAuthPreferenceRepository } from "../../db/repositories/auth-preference-repository"; import type { IAuthSessionRepository, @@ -13,6 +15,10 @@ import { MAIN_TOKENS } from "../../di/tokens"; import { decrypt, encrypt } from "../../utils/encryption"; import { logger } from "../../utils/logger"; import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import { + ConnectivityEvent, + type ConnectivityStatusOutput, +} from "../connectivity/schemas"; import type { ConnectivityService } from "../connectivity/service"; import type { OAuthService } from "../oauth/service"; import { @@ -67,7 +73,6 @@ export class AuthService extends TypedEventEmitter { private session: InMemorySession | null = null; private initializePromise: Promise | null = null; private refreshPromise: Promise | null = null; - constructor( @inject(MAIN_TOKENS.AuthPreferenceRepository) private readonly authPreferenceRepository: IAuthPreferenceRepository, @@ -80,7 +85,6 @@ export class AuthService extends TypedEventEmitter { ) { super(); } - async initialize(): Promise { if (this.initializePromise) { return this.initializePromise; @@ -89,11 +93,9 @@ export class AuthService extends TypedEventEmitter { this.initializePromise = this.doInitialize(); return this.initializePromise; } - getState(): AuthState { return { ...this.state }; } - async login(region: CloudRegion): Promise { await this.authenticateWithFlow( () => this.oauthService.startFlow(region), @@ -102,7 +104,6 @@ export class AuthService extends TypedEventEmitter { ); return this.getState(); } - async signup(region: CloudRegion): Promise { await this.authenticateWithFlow( () => this.oauthService.startSignupFlow(region), @@ -111,7 +112,6 @@ export class AuthService extends TypedEventEmitter { ); return this.getState(); } - async getValidAccessToken(): Promise { await this.initialize(); @@ -121,7 +121,6 @@ export class AuthService extends TypedEventEmitter { apiHost: getCloudUrlFromRegion(session.cloudRegion), }; } - async refreshAccessToken(): Promise { await this.initialize(); @@ -131,7 +130,6 @@ export class AuthService extends TypedEventEmitter { apiHost: getCloudUrlFromRegion(session.cloudRegion), }; } - async invalidateAccessTokenForTest(): Promise { await this.initialize(); @@ -147,7 +145,6 @@ export class AuthService extends TypedEventEmitter { accessTokenExpiresAt: Date.now() + 5 * 60 * 1000, }; } - async authenticatedFetch( fetchImpl: FetchLike, input: string | Request, @@ -173,17 +170,17 @@ export class AuthService extends TypedEventEmitter { return response; } - async redeemInviteCode(code: string): Promise { - const { accessToken, apiHost } = await this.getValidAccessToken(); - const response = await fetch(`${apiHost}/api/code/invites/redeem/`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, + const { apiHost } = await this.getValidAccessToken(); + const response = await this.authenticatedFetch( + fetch, + `${apiHost}/api/code/invites/redeem/`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code }), }, - body: JSON.stringify({ code }), - }); + ); const data = (await response.json().catch(() => ({}))) as { success?: boolean; @@ -197,7 +194,6 @@ export class AuthService extends TypedEventEmitter { this.updateState({ hasCodeAccess: true }); return this.getState(); } - async selectProject(projectId: number): Promise { await this.initialize(); @@ -222,7 +218,6 @@ export class AuthService extends TypedEventEmitter { this.updateState({ projectId }); return this.getState(); } - async logout(): Promise { const { cloudRegion, projectId } = this.state; @@ -231,7 +226,6 @@ export class AuthService extends TypedEventEmitter { this.setAnonymousState({ cloudRegion, projectId }); return this.getState(); } - private executeAuthenticatedFetch( fetchImpl: FetchLike, input: string | Request, @@ -246,7 +240,6 @@ export class AuthService extends TypedEventEmitter { headers, }); } - private async doInitialize(): Promise { const stored = this.authSessionRepository.getCurrent(); @@ -266,13 +259,7 @@ export class AuthService extends TypedEventEmitter { return; } - const storedSession = this.getStoredSessionInput( - stored.refreshTokenEncrypted, - { - cloudRegion: stored.cloudRegion, - selectedProjectId: stored.selectedProjectId, - }, - ); + const storedSession = this.resolveStoredSession(); if (!storedSession) { log.warn("Stored auth session could not be decrypted"); this.authSessionRepository.clearCurrent(); @@ -292,7 +279,6 @@ export class AuthService extends TypedEventEmitter { }); } } - private async ensureValidSession( forceRefresh = false, ): Promise { @@ -328,25 +314,13 @@ export class AuthService extends TypedEventEmitter { }; } - const stored = this.authSessionRepository.getCurrent(); - if (!stored) { - throw new Error("Not authenticated"); - } - - const storedSession = this.getStoredSessionInput( - stored.refreshTokenEncrypted, - { - cloudRegion: stored.cloudRegion, - selectedProjectId: stored.selectedProjectId, - }, - ); + const storedSession = this.resolveStoredSession(); if (!storedSession) { - throw new Error("Stored session is invalid"); + throw new Error("Not authenticated"); } return storedSession; } - private async refreshSession( input: StoredSessionInput, ): Promise { @@ -354,18 +328,55 @@ export class AuthService extends TypedEventEmitter { throw new Error("Offline"); } - const result = await this.oauthService.refreshToken( - input.refreshToken, - input.cloudRegion, - ); + let lastError = "Token refresh failed"; - if (!result.success || !result.data) { - throw new Error(result.error || "Token refresh failed"); + for ( + let attempt = 0; + attempt < AuthService.REFRESH_MAX_ATTEMPTS; + attempt++ + ) { + const result = await this.oauthService.refreshToken( + input.refreshToken, + input.cloudRegion, + ); + + if (result.success && result.data) { + return await this.createSessionFromTokenResponse(result.data, input); + } + + lastError = result.error || "Token refresh failed"; + + if (result.errorCode === "auth_error") { + log.warn("Refresh token rejected by server, forcing logout"); + this.authSessionRepository.clearCurrent(); + this.session = null; + this.setAnonymousState({ + cloudRegion: input.cloudRegion, + projectId: input.selectedProjectId, + }); + throw new Error(lastError); + } + + const isRetryable = + result.errorCode === "network_error" || + result.errorCode === "server_error"; + + if (!isRetryable) { + throw new Error(lastError); + } + + const isLastAttempt = attempt === AuthService.REFRESH_MAX_ATTEMPTS - 1; + if (isLastAttempt) break; + + log.warn("Transient refresh failure, retrying", { + attempt, + errorCode: result.errorCode, + }); + await sleepWithBackoff(attempt, AuthService.REFRESH_BACKOFF); } - return await this.createSessionFromTokenResponse(result.data, input); + throw new Error(lastError); } - private async createSessionFromTokenResponse( tokenResponse: AuthTokenResponse, options: TokenResponseOptions, @@ -400,7 +411,6 @@ export class AuthService extends TypedEventEmitter { return session; } - private async authenticateWithFlow( runFlow: () => Promise<{ success: boolean; @@ -421,14 +431,12 @@ export class AuthService extends TypedEventEmitter { }); await this.syncAuthenticatedSession(session); } - private async refreshAndSyncSession( input: StoredSessionInput, ): Promise { const session = await this.refreshSession(input); await this.syncAuthenticatedSession(session); } - private async syncAuthenticatedSession( session: InMemorySession, ): Promise { @@ -451,7 +459,6 @@ export class AuthService extends TypedEventEmitter { }); await this.updateCodeAccessFromSession(); } - private persistSession(input: { refreshToken: string; cloudRegion: CloudRegion; @@ -466,7 +473,6 @@ export class AuthService extends TypedEventEmitter { this.authSessionRepository.saveCurrent(row); } - private persistProjectPreference(session: InMemorySession): void { if (!session.accountKey) { return; @@ -478,26 +484,9 @@ export class AuthService extends TypedEventEmitter { lastSelectedProjectId: session.projectId, }); } - private isSessionExpiring(session: InMemorySession): boolean { return session.accessTokenExpiresAt - Date.now() <= TOKEN_EXPIRY_SKEW_MS; } - - private getStoredSessionInput( - refreshTokenEncrypted: string, - options: Omit, - ): StoredSessionInput | null { - const refreshToken = decrypt(refreshTokenEncrypted); - if (!refreshToken) { - return null; - } - - return { - refreshToken, - ...options, - }; - } - private async fetchAccountKey( accessToken: string, cloudRegion: "us" | "eu" | "dev", @@ -538,14 +527,12 @@ export class AuthService extends TypedEventEmitter { return null; } } - private requireSession(): InMemorySession { if (!this.session) { throw new Error("Not authenticated"); } return this.session; } - private setAnonymousState( partial: Pick< Partial, @@ -563,7 +550,6 @@ export class AuthService extends TypedEventEmitter { needsScopeReauth: partial.needsScopeReauth ?? false, }); } - private async updateCodeAccessFromSession(): Promise { if (!this.session) { this.updateState({ hasCodeAccess: null }); @@ -571,13 +557,12 @@ export class AuthService extends TypedEventEmitter { } try { - const response = await fetch( - `${getCloudUrlFromRegion(this.session.cloudRegion)}/api/code/invites/check-access/`, - { - headers: { - Authorization: `Bearer ${this.session.accessToken}`, - }, - }, + const apiHost = getCloudUrlFromRegion(this.session.cloudRegion); + const response = await this.executeAuthenticatedFetch( + fetch, + `${apiHost}/api/code/invites/check-access/`, + {}, + this.session.accessToken, ); const data = (await response.json().catch(() => ({}))) as { has_access?: boolean; @@ -589,6 +574,69 @@ export class AuthService extends TypedEventEmitter { this.updateState({ hasCodeAccess: false }); } } + private static readonly REFRESH_MAX_ATTEMPTS = 3; + private static readonly REFRESH_BACKOFF: BackoffOptions = { + initialDelayMs: 1_000, + maxDelayMs: 5_000, + multiplier: 2, + }; + private recoveryPromise: Promise | null = null; + private connectivityUnsubscribe: (() => void) | null = null; + @postConstruct() + init(): void { + const handler = (status: ConnectivityStatusOutput) => { + if (status.isOnline) { + this.attemptSessionRecovery(); + } + }; + this.connectivityService.on(ConnectivityEvent.StatusChange, handler); + this.connectivityUnsubscribe = () => { + this.connectivityService.off(ConnectivityEvent.StatusChange, handler); + }; + + powerMonitor.on("resume", this.handleResume); + } + @preDestroy() + shutdown(): void { + this.connectivityUnsubscribe?.(); + this.connectivityUnsubscribe = null; + powerMonitor.off("resume", this.handleResume); + } + private handleResume = (): void => { + this.attemptSessionRecovery(); + }; + private resolveStoredSession(): StoredSessionInput | null { + const stored = this.authSessionRepository.getCurrent(); + if (!stored) return null; + + const refreshToken = decrypt(stored.refreshTokenEncrypted); + if (!refreshToken) return null; + + return { + refreshToken, + cloudRegion: stored.cloudRegion, + selectedProjectId: stored.selectedProjectId, + }; + } + private attemptSessionRecovery(): void { + if (this.session) return; + if (this.recoveryPromise) return; + + const stored = this.authSessionRepository.getCurrent(); + if (!stored) return; + if (stored.scopeVersion < OAUTH_SCOPE_VERSION) return; + + const storedSession = this.resolveStoredSession(); + if (!storedSession) return; + + this.recoveryPromise = this.refreshAndSyncSession(storedSession) + .catch((error) => { + log.warn("Session recovery failed", { error }); + }) + .finally(() => { + this.recoveryPromise = null; + }); + } private updateState(partial: Partial): void { this.state = {