Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/code/src/main/di/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,10 @@ import type { URL_LAUNCHER_SERVICE } from "@posthog/platform/url-launcher";
import type { WORKSPACE_SETTINGS_SERVICE } from "@posthog/platform/workspace-settings";
import type { WorkspaceClient } from "@posthog/workspace-client/client";
import type { DatabaseService } from "@posthog/workspace-server/db/service";
import type { GIT_SERVICE as WS_GIT_SERVICE } from "@posthog/workspace-server/di/tokens";
import type {
BROWSER_TABS_SERVICE,
GIT_SERVICE as WS_GIT_SERVICE,
} from "@posthog/workspace-server/di/tokens";
import type { AgentService } from "@posthog/workspace-server/services/agent/agent";
import type {
AGENT_AUTH,
Expand All @@ -147,6 +150,7 @@ import type {
} from "@posthog/workspace-server/services/archive/ports";
import type { AUTH_PROXY_AUTH } from "@posthog/workspace-server/services/auth-proxy/identifiers";
import type { AuthProxyAuth } from "@posthog/workspace-server/services/auth-proxy/ports";
import type { IBrowserTabsService } from "@posthog/workspace-server/services/browser-tabs/service";
import type {
ENRICHMENT_AUTH,
ENRICHMENT_FILE_READER,
Expand Down Expand Up @@ -490,4 +494,5 @@ export interface MainBindings {
[UI_SERVICE]: UIService;
[MCP_APPS_SERVICE]: McpAppsService;
[SUSPENSION_SERVICE]: SuspensionService;
[BROWSER_TABS_SERVICE]: IBrowserTabsService;
}
17 changes: 17 additions & 0 deletions apps/code/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import log from "electron-log/main";
import "./utils/logger";
import "./services/index.js";
import type { AuthService } from "@posthog/core/auth/auth";
import { getAccountScope } from "@posthog/core/auth/authIdentity";
import { AuthServiceEvent } from "@posthog/core/auth/schemas";
import { focusHostModule } from "@posthog/core/focus/focus-host.module";
import {
FOCUS_SESSION_STORE,
Expand Down Expand Up @@ -38,6 +40,8 @@ import { ENVIRONMENT_CLIENT } from "@posthog/host-router/ports/environment-clien
import { FILE_WATCHER_CONTROL } from "@posthog/host-router/ports/file-watcher-control";
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
import type { DatabaseService } from "@posthog/workspace-server/db/service";
import { BROWSER_TABS_SERVICE } from "@posthog/workspace-server/di/tokens";
import type { IBrowserTabsService } from "@posthog/workspace-server/services/browser-tabs/service";
import type { ExternalAppsService } from "@posthog/workspace-server/services/external-apps/external-apps";
import {
FS_SERVICE,
Expand Down Expand Up @@ -267,6 +271,19 @@ async function initializeServices(): Promise<void> {
// Eagerly start the Discord presence service so it connects when enabled.
container.get<DiscordPresenceService>(DISCORD_PRESENCE_SERVICE);

// Tie the Channels tab strips to the signed-in user: each auth change
// repoints the tabs service at that account's persisted tabs. An undefined
// scope means the identity isn't determined yet (session still restoring,
// or the user-context fetch hasn't resolved) — leave the current scope in
// place rather than treating it as signed out.
const browserTabsService =
container.get<IBrowserTabsService>(BROWSER_TABS_SERVICE);
authService.on(AuthServiceEvent.StateChanged, (state) => {
const scope = getAccountScope(state);
if (scope === undefined) return;
browserTabsService.setAccountScope(scope);
});

await authService.initialize();

// Initialize workspace branch watcher for live branch rename detection
Expand Down
78 changes: 78 additions & 0 deletions packages/core/src/auth/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ describe("AuthService", () => {
expect(service.getState()).toEqual({
status: "anonymous",
bootstrapComplete: true,
accountKey: null,
cloudRegion: null,
orgProjectsMap: {},
currentOrgId: null,
Expand All @@ -257,6 +258,7 @@ describe("AuthService", () => {
expect(service.getState()).toEqual({
status: "anonymous",
bootstrapComplete: true,
accountKey: null,
cloudRegion: "us",
orgProjectsMap: {},
currentOrgId: null,
Expand Down Expand Up @@ -291,6 +293,7 @@ describe("AuthService", () => {
expect(service.getState()).toMatchObject({
status: "authenticated",
bootstrapComplete: true,
accountKey: "user-1",
cloudRegion: "us",
orgProjectsMap: {
"org-1": {
Expand Down Expand Up @@ -375,6 +378,81 @@ describe("AuthService", () => {
}
});

it("discards a background restore that completes after an explicit logout", async () => {
vi.useFakeTimers();
try {
seedStoredSession({ selectedProjectId: 42 });
stubAuthFetch();
let resolveRefresh!: (value: unknown) => void;
oauthFlow.refreshToken.mockReturnValue(
new Promise((resolve) => {
resolveRefresh = resolve;
}),
);

const initPromise = service.initialize();
await vi.advanceTimersByTimeAsync(20_001);
await initPromise;
expect(service.getState().status).toBe("restoring");

await service.logout();
expect(service.getState().status).toBe("anonymous");

// The old session's restore landing now must not resurrect it.
resolveRefresh(mockTokenResponse());
await vi.advanceTimersByTimeAsync(0);

expect(service.getState().status).toBe("anonymous");
expect(sessionPort.getCurrent()).toBeNull();
} finally {
vi.useRealTimers();
}
});

it("does not let a stale background restore clobber a newer login", async () => {
vi.useFakeTimers();
try {
seedStoredSession({ selectedProjectId: 42 });
stubAuthFetch();
let resolveRefresh!: (value: unknown) => void;
oauthFlow.refreshToken.mockReturnValue(
new Promise((resolve) => {
resolveRefresh = resolve;
}),
);

const initPromise = service.initialize();
await vi.advanceTimersByTimeAsync(20_001);
await initPromise;
expect(service.getState().status).toBe("restoring");

await service.logout();
oauthFlow.startFlow.mockResolvedValue(
mockTokenResponse({
accessToken: "login-access-token",
refreshToken: "login-refresh-token",
}),
);
await service.login("us");
const afterLogin = service.getState();
expect(afterLogin.status).toBe("authenticated");

// The pre-logout restore resolving late must not overwrite the new
// session's state or stored refresh token.
resolveRefresh(
mockTokenResponse({ refreshToken: "stale-refresh-token" }),
);
await vi.advanceTimersByTimeAsync(0);

expect(service.getState()).toEqual(afterLogin);
expect(sessionPort.getCurrent()?.refreshTokenEncrypted).toBe(
"login-refresh-token",
);
} finally {
vi.useRealTimers();
}
});

it("shares the in-flight bootstrap refresh with token callers after the deadline", async () => {
vi.useFakeTimers();
try {
Expand Down
57 changes: 48 additions & 9 deletions packages/core/src/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
private state: AuthState = {
status: "anonymous",
bootstrapComplete: false,
accountKey: null,
cloudRegion: null,
orgProjectsMap: {},
currentOrgId: null,
Expand All @@ -86,6 +87,13 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
private session: InMemorySession | null = null;
private initializePromise: Promise<void> | null = null;
private refreshPromise: Promise<InMemorySession> | null = null;
/**
* Bumped whenever the active session is discarded or replaced (logout, a
* new login flow). Async restore/refresh paths capture it before awaiting
* and abandon their result if it moved — a background restore finishing
* after a logout must not resurrect the old session or clobber a newer one.
*/
private sessionEpoch = 0;
constructor(
@inject(AUTH_PREFERENCE_STORE)
private readonly authPreference: IAuthPreferenceStore,
Expand Down Expand Up @@ -374,6 +382,7 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
async logout(): Promise<AuthState> {
const { cloudRegion, currentProjectId } = this.state;

this.sessionEpoch++;
this.authSession.clearCurrent();
this.session = null;
this.setAnonymousState({ cloudRegion, currentProjectId });
Expand Down Expand Up @@ -423,6 +432,10 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {

this.setRestoringState(storedSession, false);

// Guards every deferred handler below: after a logout or new login the
// restore's outcome (success or failure) is about a dead session and must
// not touch state.
const epoch = this.sessionEpoch;
try {
const restore = this.ensureValidSession().then(() => undefined);
const outcome = await withTimeout(restore, AUTH_BOOTSTRAP_DEADLINE_MS);
Expand All @@ -439,12 +452,16 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
this.logger.warn("Background auth restore failed after deadline", {
error,
});
this.handleStoredSessionRestoreFailure(storedSession);
if (epoch === this.sessionEpoch) {
this.handleStoredSessionRestoreFailure(storedSession);
}
});
}
} catch (error) {
this.logger.warn("Failed to restore stored auth session", { error });
this.handleStoredSessionRestoreFailure(storedSession);
if (epoch === this.sessionEpoch) {
this.handleStoredSessionRestoreFailure(storedSession);
}
}
}

Expand All @@ -456,6 +473,7 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
this.updateState({
status: "restoring",
bootstrapComplete,
accountKey: null,
cloudRegion: storedSession.cloudRegion,
orgProjectsMap: {},
currentOrgId: null,
Expand Down Expand Up @@ -502,7 +520,13 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
const sessionInput = this.getSessionInputForRefresh();

const refreshAndSync = async (): Promise<InMemorySession> => {
const session = await this.refreshSession(sessionInput);
const epoch = this.sessionEpoch;
const session = await this.refreshSession(sessionInput, epoch);
if (epoch !== this.sessionEpoch) {
// The session this refresh belonged to was logged out or replaced
// while the request was in flight; publishing it would resurrect it.
throw new NotAuthenticatedError();
}
await this.syncAuthenticatedSession(session);
return session;
};
Expand Down Expand Up @@ -532,6 +556,7 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
}
private async refreshSession(
input: StoredSessionInput,
epoch: number,
): Promise<InMemorySession> {
if (!this.connectivity.getStatus().isOnline) {
throw new Error("Offline");
Expand All @@ -557,12 +582,17 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {

if (result.errorCode === "auth_error") {
this.logger.warn("Refresh token rejected by server, forcing logout");
this.authSession.clearCurrent();
this.session = null;
this.setAnonymousState({
cloudRegion: input.cloudRegion,
currentProjectId: input.selectedProjectId,
});
// Only force the logout if this refresh's session is still current —
// a stale rejection must not clear a session the user re-created in
// the meantime.
if (epoch === this.sessionEpoch) {
this.authSession.clearCurrent();
this.session = null;
this.setAnonymousState({
cloudRegion: input.cloudRegion,
currentProjectId: input.selectedProjectId,
});
}
throw new Error(lastError);
}

Expand Down Expand Up @@ -751,6 +781,10 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
region: CloudRegion,
fallbackError: string,
): Promise<void> {
// A fresh login supersedes whatever session any in-flight restore or
// refresh belongs to; bumping the epoch makes those abandon their result.
this.sessionEpoch++;
const epoch = this.sessionEpoch;
const result = await runFlow();
if (!result.success || !result.data) {
throw new Error(result.error || fallbackError);
Expand All @@ -760,6 +794,9 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
cloudRegion: region,
selectedProjectId: this.state.currentProjectId,
});
if (epoch !== this.sessionEpoch) {
throw new Error("Session was replaced while signing in");
}
await this.syncAuthenticatedSession(session);
}
private async syncAuthenticatedSession(
Expand All @@ -776,6 +813,7 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
this.updateState({
status: "authenticated",
bootstrapComplete: true,
accountKey: session.accountKey,
cloudRegion: session.cloudRegion,
orgProjectsMap: session.orgProjectsMap,
currentOrgId: session.currentOrgId,
Expand Down Expand Up @@ -896,6 +934,7 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
this.updateState({
status: "anonymous",
bootstrapComplete: partial.bootstrapComplete ?? true,
accountKey: null,
cloudRegion: partial.cloudRegion ?? null,
orgProjectsMap: {},
currentOrgId: null,
Expand Down
50 changes: 50 additions & 0 deletions packages/core/src/auth/authIdentity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { getAccountScope } from "./authIdentity";
import type { AuthState } from "./schemas";

const state = (partial: Partial<AuthState>): AuthState => ({
status: "anonymous",
bootstrapComplete: true,
accountKey: null,
cloudRegion: null,
orgProjectsMap: {},
currentOrgId: null,
currentProjectId: null,
hasCodeAccess: null,
needsScopeReauth: false,
...partial,
});

describe("getAccountScope", () => {
it("resolves the scope for a known authenticated identity", () => {
expect(
getAccountScope(
state({
status: "authenticated",
accountKey: "user-1",
cloudRegion: "us",
}),
),
).toEqual({ accountKey: "user-1", cloudRegion: "us" });
});

it("is null when signed out", () => {
expect(getAccountScope(state({ status: "anonymous" }))).toBeNull();
});

// Undefined = "don't know who this is yet", distinct from "signed out":
// callers must leave per-user state untouched instead of clearing it.
it.each([
["restoring", state({ status: "restoring", cloudRegion: "us" })],
[
"authenticated without accountKey",
state({ status: "authenticated", cloudRegion: "us" }),
],
[
"authenticated without cloudRegion",
state({ status: "authenticated", accountKey: "user-1" }),
],
])("is undefined while the identity is undetermined (%s)", (_name, s) => {
expect(getAccountScope(s)).toBeUndefined();
});
});
Loading
Loading