From a3bdd4ccfeea7a5a403aa4b54504b19309eb3170 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Tue, 14 Jul 2026 15:21:55 -0400 Subject: [PATCH 1/5] refactor(auth): make the token cipher async IAuthTokenCipher.encrypt/decrypt become Promise-returning so a host can back them with an async primitive (e.g. the browser's Web Crypto, which is async). Thread await through the AuthService call sites; the desktop adapter wraps its existing synchronous node:crypto cipher in Promise.resolve, so desktop behavior is unchanged. Enabling change for the browser web host, but self-contained and useful on its own. Generated-By: PostHog Code Task-Id: 77d13a30-444d-4045-9d80-5e2a9f2e68ae --- .../src/main/services/auth/port-adapters.ts | 8 +-- packages/core/src/auth/auth.test.ts | 4 +- packages/core/src/auth/auth.ts | 56 +++++++++++-------- packages/core/src/auth/identifiers.ts | 7 ++- 4 files changed, 42 insertions(+), 33 deletions(-) diff --git a/apps/code/src/main/services/auth/port-adapters.ts b/apps/code/src/main/services/auth/port-adapters.ts index c354a9e5d5..784d379df1 100644 --- a/apps/code/src/main/services/auth/port-adapters.ts +++ b/apps/code/src/main/services/auth/port-adapters.ts @@ -37,12 +37,12 @@ import { @injectable() export class TokenCipherPortAdapter implements IAuthTokenCipher { - encrypt(plaintext: string): string { - return encrypt(plaintext); + encrypt(plaintext: string): Promise { + return Promise.resolve(encrypt(plaintext)); } - decrypt(encrypted: string): string | null { - return decrypt(encrypted); + decrypt(encrypted: string): Promise { + return Promise.resolve(decrypt(encrypted)); } } diff --git a/packages/core/src/auth/auth.test.ts b/packages/core/src/auth/auth.test.ts index 00bd2ee104..4b01e75b4a 100644 --- a/packages/core/src/auth/auth.test.ts +++ b/packages/core/src/auth/auth.test.ts @@ -64,8 +64,8 @@ function createPreferencePort(): IAuthPreferenceStore { } const identityCipher: IAuthTokenCipher = { - encrypt: (plaintext) => plaintext, - decrypt: (encrypted) => encrypted, + encrypt: (plaintext) => Promise.resolve(plaintext), + decrypt: (encrypted) => Promise.resolve(encrypted), }; const mockLogger: RootLogger = { diff --git a/packages/core/src/auth/auth.ts b/packages/core/src/auth/auth.ts index 18e3882e88..4253aca900 100644 --- a/packages/core/src/auth/auth.ts +++ b/packages/core/src/auth/auth.ts @@ -254,7 +254,7 @@ export class AuthService extends TypedEventEmitter { ? await this.applyOrgChange(session, newOrgId) : session.orgProjectsMap; - this.commitSessionState(session, { + await this.commitSessionState(session, { orgProjectsMap, currentOrgId: newOrgId, currentProjectId: projectId, @@ -277,7 +277,7 @@ export class AuthService extends TypedEventEmitter { orgId, ); - this.commitSessionState(session, { + await this.commitSessionState(session, { orgProjectsMap, currentOrgId: orgId, currentProjectId, @@ -326,14 +326,14 @@ export class AuthService extends TypedEventEmitter { } return orgProjects[0]?.id ?? null; } - private commitSessionState( + private async commitSessionState( prevSession: InMemorySession, next: { orgProjectsMap: OrgProjectsMap; currentOrgId: string | null; currentProjectId: number | null; }, - ): void { + ): Promise { this.session = { ...prevSession, orgProjectsMap: next.orgProjectsMap, @@ -343,7 +343,7 @@ export class AuthService extends TypedEventEmitter { }; this.persistProjectPreference(this.session); - this.persistSession({ + await this.persistSession({ refreshToken: this.session.refreshToken, cloudRegion: this.session.cloudRegion, selectedProjectId: next.currentProjectId, @@ -413,7 +413,7 @@ export class AuthService extends TypedEventEmitter { return; } - const storedSession = this.resolveStoredSession(); + const storedSession = await this.resolveStoredSession(); if (!storedSession) { this.logger.warn("Stored auth session could not be decrypted"); this.authSession.clearCurrent(); @@ -500,7 +500,7 @@ export class AuthService extends TypedEventEmitter { return this.refreshPromise; } - const sessionInput = this.getSessionInputForRefresh(); + const sessionInput = await this.getSessionInputForRefresh(); const refreshAndSync = async (): Promise => { let session: InMemorySession; @@ -532,7 +532,7 @@ export class AuthService extends TypedEventEmitter { return this.refreshPromise; } - private getSessionInputForRefresh(): StoredSessionInput { + private async getSessionInputForRefresh(): Promise { if (this.session) { return { refreshToken: this.session.refreshToken, @@ -541,7 +541,7 @@ export class AuthService extends TypedEventEmitter { }; } - const storedSession = this.resolveStoredSession(); + const storedSession = await this.resolveStoredSession(); if (!storedSession) { throw new NotAuthenticatedError(); } @@ -794,7 +794,7 @@ export class AuthService extends TypedEventEmitter { session: InMemorySession, ): Promise { this.persistProjectPreference(session); - this.persistSession({ + await this.persistSession({ refreshToken: session.refreshToken, cloudRegion: session.cloudRegion, selectedProjectId: session.currentProjectId, @@ -816,15 +816,15 @@ export class AuthService extends TypedEventEmitter { void this.refreshOrgProjects(); } } - private persistSession(input: { + private async persistSession(input: { refreshToken: string; cloudRegion: CloudRegion; selectedProjectId: number | null; - }): void { + }): Promise { const priorSelected = this.authSession.getCurrent()?.selectedProjectId ?? null; this.authSession.saveCurrent({ - refreshTokenEncrypted: this.cipher.encrypt(input.refreshToken), + refreshTokenEncrypted: await this.cipher.encrypt(input.refreshToken), cloudRegion: input.cloudRegion, selectedProjectId: input.selectedProjectId ?? priorSelected, scopeVersion: OAUTH_SCOPE_VERSION, @@ -1051,11 +1051,13 @@ export class AuthService extends TypedEventEmitter { private handleResume = (): void => { this.attemptSessionRecovery(); }; - private resolveStoredSession(): StoredSessionInput | null { + private async resolveStoredSession(): Promise { const stored = this.authSession.getCurrent(); if (!stored) return null; - const refreshToken = this.cipher.decrypt(stored.refreshTokenEncrypted); + const refreshToken = await this.cipher.decrypt( + stored.refreshTokenEncrypted, + ); if (!refreshToken) return null; return { @@ -1077,14 +1079,10 @@ export class AuthService extends TypedEventEmitter { if (!stored) return; if (stored.scopeVersion < OAUTH_SCOPE_VERSION) return; - if (!this.resolveStoredSession()) return; - - // Route through ensureValidSession so a refresh already in flight (e.g. the - // background bootstrap restore past its deadline) is shared instead of - // kicking a second concurrent token refresh that would burn the same - // rotating refresh token twice. - this.recoveryPromise = this.ensureValidSession() - .then(() => undefined) + // Claim the recovery slot synchronously so concurrent triggers (network + // regained + tab resume) don't both kick a token refresh; decryptability + // is now async (Web Crypto), so it's validated inside recoverSession. + this.recoveryPromise = this.recoverSession() .catch((error) => { this.logger.warn("Session recovery failed", { error }); }) @@ -1092,6 +1090,16 @@ export class AuthService extends TypedEventEmitter { this.recoveryPromise = null; }); } + private async recoverSession(): Promise { + // Bail before touching the network if the stored token can't be decrypted. + if (!(await this.resolveStoredSession())) return; + + // Route through ensureValidSession so a refresh already in flight (e.g. the + // background bootstrap restore past its deadline) is shared instead of + // kicking a second concurrent token refresh that would burn the same + // rotating refresh token twice. + await this.ensureValidSession(); + } private refreshOrgProjects(): Promise { if (this.orgProjectsRefreshPromise) { @@ -1157,7 +1165,7 @@ export class AuthService extends TypedEventEmitter { null, lastSelectedOrgId: lastPrefs?.lastSelectedOrgId ?? null, }); - this.commitSessionState(session, { + await this.commitSessionState(session, { orgProjectsMap: map, currentOrgId: session.currentOrgId, currentProjectId, diff --git a/packages/core/src/auth/identifiers.ts b/packages/core/src/auth/identifiers.ts index 300efbd74a..b0cb002595 100644 --- a/packages/core/src/auth/identifiers.ts +++ b/packages/core/src/auth/identifiers.ts @@ -89,11 +89,12 @@ export const AUTH_OAUTH_FLOW_SERVICE = Symbol.for( /** * Machine-bound symmetric cipher for the refresh token at rest. Desktop adapter - * wraps the existing encryption util (node:crypto + machine id). + * wraps the existing encryption util (node:crypto + machine id); the web adapter + * uses a non-extractable Web Crypto key (async), so the contract is async. */ export interface IAuthTokenCipher { - encrypt(plaintext: string): string; - decrypt(encrypted: string): string | null; + encrypt(plaintext: string): Promise; + decrypt(encrypted: string): Promise; } export const AUTH_TOKEN_CIPHER = Symbol.for("posthog.core.auth.tokenCipher"); From 3efb396f5e7e3543fa49814acea178f6471c4552 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Tue, 14 Jul 2026 16:25:54 -0400 Subject: [PATCH 2/5] fix(auth): commit session state only after the persist succeeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commitSessionState mutated this.session and saved the project preference before awaiting persistSession. Now that encryption is async and can reject, a failure left the service on the new project in memory (and in the saved preference) while the stored session and published state stayed on the old one — a partial, inconsistent commit the caller saw only as a rejected promise. Build the next session locally, await the persist first (the only fallible step), then assign this.session, save the preference, and publish state — so a rejection leaves every layer on the prior session. Add a regression test that fails encryption mid-selectProject and asserts nothing moved. Generated-By: PostHog Code Task-Id: 77d13a30-444d-4045-9d80-5e2a9f2e68ae --- packages/core/src/auth/auth.test.ts | 64 +++++++++++++++++++++++++++++ packages/core/src/auth/auth.ts | 15 +++++-- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/packages/core/src/auth/auth.test.ts b/packages/core/src/auth/auth.test.ts index 4b01e75b4a..9736b1651a 100644 --- a/packages/core/src/auth/auth.test.ts +++ b/packages/core/src/auth/auth.test.ts @@ -500,6 +500,70 @@ describe("AuthService", () => { }); }); + it("keeps the prior selection committed when persisting a new selection fails", async () => { + const orgs = { + "org-1": { + name: "Org 1", + projects: [ + { id: 42, name: "Project 42" }, + { id: 84, name: "Project 84" }, + ], + }, + }; + oauthFlow.startFlow.mockResolvedValue( + mockTokenResponse({ + accessToken: "initial-access-token", + refreshToken: "initial-refresh-token", + }), + ); + stubAuthFetch({ orgs }); + + // Encrypts fine during login, then rejects so the selectProject persist + // fails mid-flow (mirrors the browser cipher's Web Crypto rejecting). + let failEncrypt = false; + const flakyCipher: IAuthTokenCipher = { + encrypt: (plaintext) => + failEncrypt + ? Promise.reject(new Error("encryption unavailable")) + : Promise.resolve(plaintext), + decrypt: (encrypted) => Promise.resolve(encrypted), + }; + service = new AuthService( + preferencePort, + sessionPort, + oauthFlow as unknown as IAuthOAuthFlowService, + connectivity, + flakyCipher, + mockPowerManager as unknown as IPowerManager, + mockLogger, + null, + ); + + // Initialize once while the session store is empty so login/selectProject + // don't later trigger the stored-session restore path. + service.init(); + await service.initialize(); + + await service.login("us"); + const priorProjectId = service.getState().currentProjectId; + expect(priorProjectId).toBe(42); + + failEncrypt = true; + await expect(service.selectProject(84)).rejects.toThrow( + "encryption unavailable", + ); + + // A failed persist must not commit the new project anywhere: published + // state, the stored session, and the saved preference all stay on the + // prior selection (the preference is the tell — the buggy order saved it + // to 84 before the encrypt rejected). + expect(service.getState().currentProjectId).toBe(priorProjectId); + expect(sessionPort.getCurrent()?.selectedProjectId).toBe(priorProjectId); + expect(preferencePort.get("user-1", "us")?.lastSelectedProjectId).toBe( + priorProjectId, + ); + }); + it("restores the selected project after app restart while logged out", async () => { const orgs = { "org-1": { diff --git a/packages/core/src/auth/auth.ts b/packages/core/src/auth/auth.ts index 4253aca900..7f02a6738a 100644 --- a/packages/core/src/auth/auth.ts +++ b/packages/core/src/auth/auth.ts @@ -334,7 +334,7 @@ export class AuthService extends TypedEventEmitter { currentProjectId: number | null; }, ): Promise { - this.session = { + const nextSession: InMemorySession = { ...prevSession, orgProjectsMap: next.orgProjectsMap, currentOrgId: next.currentOrgId, @@ -342,13 +342,20 @@ export class AuthService extends TypedEventEmitter { orgProjectsIncomplete: false, }; - this.persistProjectPreference(this.session); + // Persist the durable session first — it's the only step here that can + // fail (encryption is async and may reject). Mutating this.session, the + // project preference, or the published state before it would strand the + // service on a project change that the stored session and UI never + // committed. Commit those only after the persist resolves, so a rejection + // leaves every layer on the prior session. await this.persistSession({ - refreshToken: this.session.refreshToken, - cloudRegion: this.session.cloudRegion, + refreshToken: nextSession.refreshToken, + cloudRegion: nextSession.cloudRegion, selectedProjectId: next.currentProjectId, }); + this.session = nextSession; + this.persistProjectPreference(nextSession); this.updateState({ orgProjectsMap: next.orgProjectsMap, currentOrgId: next.currentOrgId, From 7023c2c75af8af9cfb716f0046734b3ff14d3e87 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Tue, 14 Jul 2026 16:35:40 -0400 Subject: [PATCH 3/5] fix(auth): close the refresh dedup guard synchronously MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Making getSessionInputForRefresh async put an await (stored-token decryption) between ensureValidSession's `if (this.refreshPromise)` guard and the `this.refreshPromise = ...` assignment. Two concurrent callers could both observe a null refreshPromise, both decrypt, then both start a refresh — spending the rotating refresh token twice, so the second request fails with auth_error and clears the session the first one established, logging the user out. Move the decryption inside refreshAndSync so refreshPromise is assigned synchronously right after the guard, restoring single-flight dedup. Add a regression test firing two concurrent refreshAccessToken() calls and asserting one underlying token request. Generated-By: PostHog Code Task-Id: 77d13a30-444d-4045-9d80-5e2a9f2e68ae --- packages/core/src/auth/auth.test.ts | 46 +++++++++++++++++++++++++++++ packages/core/src/auth/auth.ts | 10 +++++-- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/core/src/auth/auth.test.ts b/packages/core/src/auth/auth.test.ts index 9736b1651a..469ee1f8db 100644 --- a/packages/core/src/auth/auth.test.ts +++ b/packages/core/src/auth/auth.test.ts @@ -413,6 +413,52 @@ describe("AuthService", () => { } }); + it("dedupes concurrent refreshes into a single token request", async () => { + oauthFlow.startFlow.mockResolvedValue( + mockTokenResponse({ + accessToken: "initial-access-token", + refreshToken: "initial-refresh-token", + }), + ); + stubAuthFetch(); + + // Keep the refresh in flight so both callers overlap; if the refresh + // isn't deduped, the rotating refresh token is spent twice. + let resolveRefresh!: (value: unknown) => void; + oauthFlow.refreshToken.mockReturnValue( + new Promise((resolve) => { + resolveRefresh = resolve; + }), + ); + + await service.initialize(); + await service.login("us"); + + // Two forced refreshes fired in the same tick. The dedup guard must close + // synchronously — resolving the stored session now awaits decryption, so + // if that await sat between the guard and the refreshPromise assignment, + // both callers would slip through and fire their own request. + oauthFlow.refreshToken.mockClear(); + const both = Promise.all([ + service.refreshAccessToken(), + service.refreshAccessToken(), + ]); + + await new Promise((r) => setTimeout(r, 0)); + expect(oauthFlow.refreshToken).toHaveBeenCalledTimes(1); + + resolveRefresh( + mockTokenResponse({ + accessToken: "refreshed-access-token", + refreshToken: "refreshed-refresh-token", + }), + ); + const [a, b] = await both; + expect(a.accessToken).toBe("refreshed-access-token"); + expect(b.accessToken).toBe("refreshed-access-token"); + expect(oauthFlow.refreshToken).toHaveBeenCalledTimes(1); + }); + it("forces a token refresh when explicitly requested", async () => { oauthFlow.startFlow.mockResolvedValue( mockTokenResponse({ diff --git a/packages/core/src/auth/auth.ts b/packages/core/src/auth/auth.ts index 7f02a6738a..ff6b3a1601 100644 --- a/packages/core/src/auth/auth.ts +++ b/packages/core/src/auth/auth.ts @@ -507,9 +507,15 @@ export class AuthService extends TypedEventEmitter { return this.refreshPromise; } - const sessionInput = await this.getSessionInputForRefresh(); - + // Assign refreshPromise synchronously — with no await between the guard + // above and this assignment — so concurrent callers dedupe onto one + // refresh. Resolving the stored session (which now awaits decryption) + // must therefore happen INSIDE refreshAndSync, not before it; otherwise + // two callers both pass the null guard, both decrypt, and both fire a + // refresh, burning the rotating refresh token twice and logging the user + // out when the second request fails. const refreshAndSync = async (): Promise => { + const sessionInput = await this.getSessionInputForRefresh(); let session: InMemorySession; try { session = await this.refreshSession(sessionInput); From 2a76f87f1f1ede0072a98cb110f884ac91f7b30e Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Tue, 14 Jul 2026 16:49:25 -0400 Subject: [PATCH 4/5] fix(auth): serialize session-state commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With async encryption, two overlapping project selections could interleave across the await in commitSessionState: an earlier selection completing last would overwrite the newer stored session and published state (and persistSession's own read of the prior selection could race), leaving the committed selection stale — not the one the user picked last. Serialize commits onto a promise chain so each commit's persist-then-publish runs to completion before the next begins; the latest selection now wins consistently across the in-memory session, storage, and subscribers. The stored chain swallows rejections so a failed commit can't wedge later ones, while the returned promise still rejects for the caller. Add a regression test that overlaps two selections, drains encryption newest-first, and asserts the latest selection wins everywhere. Generated-By: PostHog Code Task-Id: 77d13a30-444d-4045-9d80-5e2a9f2e68ae --- packages/core/src/auth/auth.test.ts | 71 +++++++++++++++++++++++++++++ packages/core/src/auth/auth.ts | 28 +++++++++++- 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/core/src/auth/auth.test.ts b/packages/core/src/auth/auth.test.ts index 469ee1f8db..38be8ae05e 100644 --- a/packages/core/src/auth/auth.test.ts +++ b/packages/core/src/auth/auth.test.ts @@ -610,6 +610,77 @@ describe("AuthService", () => { ); }); + it("keeps overlapping selections consistent so the latest one wins", async () => { + const orgs = { + "org-1": { + name: "Org 1", + projects: [ + { id: 42, name: "Project 42" }, + { id: 84, name: "Project 84" }, + { id: 99, name: "Project 99" }, + ], + }, + }; + oauthFlow.startFlow.mockResolvedValue( + mockTokenResponse({ + accessToken: "initial-access-token", + refreshToken: "initial-refresh-token", + }), + ); + stubAuthFetch({ orgs }); + + // Encryption is gated so overlapping selection commits stay in flight and + // can be resolved out of order below. + let deferEncrypt = false; + const pending: Array<() => void> = []; + const gatedCipher: IAuthTokenCipher = { + encrypt: (plaintext) => + deferEncrypt + ? new Promise((resolve) => { + pending.push(() => resolve(plaintext)); + }) + : Promise.resolve(plaintext), + decrypt: (encrypted) => Promise.resolve(encrypted), + }; + service = new AuthService( + preferencePort, + sessionPort, + oauthFlow as unknown as IAuthOAuthFlowService, + connectivity, + gatedCipher, + mockPowerManager as unknown as IPowerManager, + mockLogger, + null, + ); + service.init(); + await service.initialize(); + await service.login("us"); + expect(service.getState().currentProjectId).toBe(42); + + deferEncrypt = true; + // Two overlapping selections: 84 first, then 99 (the latest intent). + const first = service.selectProject(84); + const second = service.selectProject(99); + + // Drain pending encryptions newest-first each round to surface any + // out-of-order completion, until both commits settle. Serialized commits + // only expose one pending encryption at a time; unserialized ones would + // let the stale 84 commit land last. + const flush = () => new Promise((r) => setTimeout(r, 0)); + await flush(); + while (pending.length > 0) { + for (const resolve of pending.splice(0).reverse()) resolve(); + await flush(); + } + await Promise.all([first, second]); + + // The latest selection wins everywhere — no stale overwrite and no split + // between the in-memory session (getState), stored session, and preference. + expect(service.getState().currentProjectId).toBe(99); + expect(sessionPort.getCurrent()?.selectedProjectId).toBe(99); + expect(preferencePort.get("user-1", "us")?.lastSelectedProjectId).toBe(99); + }); + it("restores the selected project after app restart while logged out", async () => { const orgs = { "org-1": { diff --git a/packages/core/src/auth/auth.ts b/packages/core/src/auth/auth.ts index ff6b3a1601..a0e4d2470e 100644 --- a/packages/core/src/auth/auth.ts +++ b/packages/core/src/auth/auth.ts @@ -86,6 +86,9 @@ export class AuthService extends TypedEventEmitter { private session: InMemorySession | null = null; private initializePromise: Promise | null = null; private refreshPromise: Promise | null = null; + // Serializes session-state commits so overlapping selections can't interleave + // across async encryption (see commitSessionState). + private commitChain: Promise = Promise.resolve(); constructor( @inject(AUTH_PREFERENCE_STORE) private readonly authPreference: IAuthPreferenceStore, @@ -326,7 +329,30 @@ export class AuthService extends TypedEventEmitter { } return orgProjects[0]?.id ?? null; } - private async commitSessionState( + private commitSessionState( + prevSession: InMemorySession, + next: { + orgProjectsMap: OrgProjectsMap; + currentOrgId: string | null; + currentProjectId: number | null; + }, + ): Promise { + // Serialize commits onto a chain. Encryption is async, so two overlapping + // selections would otherwise interleave across the await — an earlier one + // completing last would clobber the newer stored session and published + // state (and persistSession's own read of the prior selection would race). + // Chaining runs each commit's persist-then-publish to completion before the + // next starts, so the latest selection wins consistently across the + // in-memory session, storage, and subscribers. The stored chain swallows + // rejections so one failed commit doesn't wedge later ones; the returned + // promise still rejects for the caller. + const run = this.commitChain.then(() => + this.applyCommittedSession(prevSession, next), + ); + this.commitChain = run.catch(() => {}); + return run; + } + private async applyCommittedSession( prevSession: InMemorySession, next: { orgProjectsMap: OrgProjectsMap; From 4a90decb13f63a1be0b0698a8b8dfff08e2a0c21 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Wed, 15 Jul 2026 10:16:33 -0400 Subject: [PATCH 5/5] docs(auth): compact the added comment blocks Generated-By: PostHog Code Task-Id: f67230a6-4c1c-4433-91cc-ee487135fead --- packages/core/src/auth/auth.ts | 44 +++++++++++++--------------------- 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/packages/core/src/auth/auth.ts b/packages/core/src/auth/auth.ts index a0e4d2470e..9f9452ec04 100644 --- a/packages/core/src/auth/auth.ts +++ b/packages/core/src/auth/auth.ts @@ -86,8 +86,8 @@ export class AuthService extends TypedEventEmitter { private session: InMemorySession | null = null; private initializePromise: Promise | null = null; private refreshPromise: Promise | null = null; - // Serializes session-state commits so overlapping selections can't interleave - // across async encryption (see commitSessionState). + // Serializes session-state commits so overlapping selections can't + // interleave across async encryption (see commitSessionState). private commitChain: Promise = Promise.resolve(); constructor( @inject(AUTH_PREFERENCE_STORE) @@ -337,15 +337,10 @@ export class AuthService extends TypedEventEmitter { currentProjectId: number | null; }, ): Promise { - // Serialize commits onto a chain. Encryption is async, so two overlapping - // selections would otherwise interleave across the await — an earlier one - // completing last would clobber the newer stored session and published - // state (and persistSession's own read of the prior selection would race). - // Chaining runs each commit's persist-then-publish to completion before the - // next starts, so the latest selection wins consistently across the - // in-memory session, storage, and subscribers. The stored chain swallows - // rejections so one failed commit doesn't wedge later ones; the returned - // promise still rejects for the caller. + // Serialize commits onto a chain so overlapping selections can't + // interleave across async encryption and clobber a newer one. The chain + // swallows rejections so one failure doesn't wedge later commits; the + // returned promise still rejects for the caller. const run = this.commitChain.then(() => this.applyCommittedSession(prevSession, next), ); @@ -368,12 +363,10 @@ export class AuthService extends TypedEventEmitter { orgProjectsIncomplete: false, }; - // Persist the durable session first — it's the only step here that can - // fail (encryption is async and may reject). Mutating this.session, the - // project preference, or the published state before it would strand the - // service on a project change that the stored session and UI never - // committed. Commit those only after the persist resolves, so a rejection - // leaves every layer on the prior session. + // Persist the durable session first — the only step that can fail (async + // encryption may reject). Mutate this.session, the preference, and + // published state only after it resolves, so a rejection leaves every + // layer on the prior session. await this.persistSession({ refreshToken: nextSession.refreshToken, cloudRegion: nextSession.cloudRegion, @@ -533,13 +526,10 @@ export class AuthService extends TypedEventEmitter { return this.refreshPromise; } - // Assign refreshPromise synchronously — with no await between the guard - // above and this assignment — so concurrent callers dedupe onto one - // refresh. Resolving the stored session (which now awaits decryption) - // must therefore happen INSIDE refreshAndSync, not before it; otherwise - // two callers both pass the null guard, both decrypt, and both fire a - // refresh, burning the rotating refresh token twice and logging the user - // out when the second request fails. + // Assign refreshPromise synchronously — no await before this — so + // concurrent callers dedupe onto one refresh. Resolving the stored session + // (now async) must happen INSIDE refreshAndSync, else two callers both + // refresh and burn the rotating token twice. const refreshAndSync = async (): Promise => { const sessionInput = await this.getSessionInputForRefresh(); let session: InMemorySession; @@ -1118,9 +1108,9 @@ export class AuthService extends TypedEventEmitter { if (!stored) return; if (stored.scopeVersion < OAUTH_SCOPE_VERSION) return; - // Claim the recovery slot synchronously so concurrent triggers (network - // regained + tab resume) don't both kick a token refresh; decryptability - // is now async (Web Crypto), so it's validated inside recoverSession. + // Claim the recovery slot synchronously so concurrent triggers don't both + // kick a token refresh; decryptability is now async (Web Crypto), so it's + // validated inside recoverSession. this.recoveryPromise = this.recoverSession() .catch((error) => { this.logger.warn("Session recovery failed", { error });