Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 3389f0a

Browse files
authored
feat(auth): support short-lived impersonated sessions (#3792)
1 parent 4ba879c commit 3389f0a

12 files changed

Lines changed: 384 additions & 28 deletions

File tree

packages/core/src/auth/auth.test.ts

Lines changed: 177 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,16 +79,21 @@ const mockLogger: RootLogger = {
7979
function mockTokenResponse(
8080
overrides: {
8181
accessToken?: string;
82-
refreshToken?: string;
82+
refreshToken?: string | null;
83+
expiresIn?: number;
8384
scopedOrgs?: string[];
8485
} = {},
8586
) {
87+
const refreshToken =
88+
overrides.refreshToken === undefined
89+
? "refresh-token"
90+
: overrides.refreshToken;
8691
return {
8792
success: true as const,
8893
data: {
8994
access_token: overrides.accessToken ?? "access-token",
90-
refresh_token: overrides.refreshToken ?? "refresh-token",
91-
expires_in: 3600,
95+
...(refreshToken ? { refresh_token: refreshToken } : {}),
96+
expires_in: overrides.expiresIn ?? 3600,
9297
token_type: "Bearer",
9398
scope: "",
9499
scoped_organizations: overrides.scopedOrgs ?? ["org-1"],
@@ -242,9 +247,157 @@ describe("AuthService", () => {
242247
currentProjectId: null,
243248
hasCodeAccess: null,
244249
needsScopeReauth: false,
250+
sessionType: null,
251+
sessionExpiresAt: null,
252+
sessionEndReason: null,
245253
});
246254
});
247255

256+
it("uses an impersonated session without persisting it", async () => {
257+
oauthFlow.startFlow.mockResolvedValue(
258+
mockTokenResponse({ refreshToken: null }),
259+
);
260+
stubAuthFetch();
261+
262+
await service.initialize();
263+
await service.login("us");
264+
265+
expect(service.getState()).toMatchObject({
266+
status: "authenticated",
267+
sessionType: "impersonated",
268+
sessionExpiresAt: expect.any(Number),
269+
});
270+
await expect(service.getValidAccessToken()).resolves.toMatchObject({
271+
accessToken: "access-token",
272+
});
273+
expect(sessionPort.getCurrent()).toBeNull();
274+
});
275+
276+
it("signs out an impersonated session when a refresh is required", async () => {
277+
oauthFlow.startFlow.mockResolvedValue(
278+
mockTokenResponse({ refreshToken: null }),
279+
);
280+
stubAuthFetch();
281+
282+
await service.initialize();
283+
await service.login("us");
284+
285+
await expect(service.refreshAccessToken()).rejects.toThrow(
286+
"Your impersonated session has expired",
287+
);
288+
expect(service.getState()).toMatchObject({
289+
status: "anonymous",
290+
sessionType: null,
291+
sessionExpiresAt: null,
292+
sessionEndReason: "impersonation_expired",
293+
});
294+
expect(oauthFlow.refreshToken).not.toHaveBeenCalled();
295+
});
296+
297+
it("signs out when an impersonated access token expires", async () => {
298+
vi.useFakeTimers();
299+
try {
300+
oauthFlow.startFlow.mockResolvedValue(
301+
mockTokenResponse({ refreshToken: null, expiresIn: 61 }),
302+
);
303+
stubAuthFetch();
304+
305+
await service.initialize();
306+
await service.login("us");
307+
await vi.advanceTimersByTimeAsync(60_000);
308+
309+
await expect(service.getValidAccessToken()).resolves.toMatchObject({
310+
accessToken: "access-token",
311+
});
312+
313+
await vi.advanceTimersByTimeAsync(1_001);
314+
315+
expect(service.getState()).toMatchObject({
316+
status: "anonymous",
317+
sessionEndReason: "impersonation_expired",
318+
});
319+
} finally {
320+
vi.useRealTimers();
321+
}
322+
});
323+
324+
it("does not persist or extend impersonated credentials after a project change", async () => {
325+
vi.useFakeTimers();
326+
try {
327+
oauthFlow.startFlow.mockResolvedValue(
328+
mockTokenResponse({ refreshToken: null, expiresIn: 61 }),
329+
);
330+
stubAuthFetch({
331+
orgs: {
332+
"org-1": {
333+
name: "Org 1",
334+
projects: [
335+
{ id: 42, name: "Project 42" },
336+
{ id: 84, name: "Project 84" },
337+
],
338+
},
339+
},
340+
});
341+
342+
await service.initialize();
343+
await service.login("us");
344+
await service.selectProject(84);
345+
346+
expect(service.getState().currentProjectId).toBe(84);
347+
expect(sessionPort.getCurrent()).toBeNull();
348+
349+
await vi.advanceTimersByTimeAsync(61_001);
350+
351+
expect(service.getState()).toMatchObject({
352+
status: "anonymous",
353+
sessionEndReason: "impersonation_expired",
354+
});
355+
} finally {
356+
vi.useRealTimers();
357+
}
358+
});
359+
360+
it("does not restore an impersonated session after restart", async () => {
361+
oauthFlow.startFlow.mockResolvedValue(
362+
mockTokenResponse({ refreshToken: null }),
363+
);
364+
stubAuthFetch();
365+
366+
await service.initialize();
367+
await service.login("us");
368+
service.shutdown();
369+
370+
service = createService();
371+
service.init();
372+
await service.initialize();
373+
374+
expect(service.getState()).toMatchObject({
375+
status: "anonymous",
376+
sessionType: null,
377+
});
378+
expect(oauthFlow.refreshToken).not.toHaveBeenCalled();
379+
});
380+
381+
it("returns permission-denied responses without ending impersonation", async () => {
382+
oauthFlow.startFlow.mockResolvedValue(
383+
mockTokenResponse({ refreshToken: null }),
384+
);
385+
stubAuthFetch();
386+
387+
await service.initialize();
388+
await service.login("us");
389+
vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 403 }));
390+
391+
const response = await service.authenticatedFetch(
392+
fetch,
393+
"https://us.posthog.com/api/restricted/",
394+
);
395+
396+
expect(response.status).toBe(403);
397+
expect(service.getState().status).toBe("authenticated");
398+
expect(oauthFlow.refreshToken).not.toHaveBeenCalled();
399+
});
400+
248401
it("requires scope reauthentication when the stored scope version is stale", async () => {
249402
seedStoredSession({
250403
refreshToken: "refresh-token",
@@ -263,6 +416,9 @@ describe("AuthService", () => {
263416
currentProjectId: 123,
264417
hasCodeAccess: null,
265418
needsScopeReauth: true,
419+
sessionType: null,
420+
sessionExpiresAt: null,
421+
sessionEndReason: null,
266422
});
267423
});
268424

@@ -312,6 +468,24 @@ describe("AuthService", () => {
312468
);
313469
});
314470

471+
it("keeps the existing refresh token when the server does not rotate it", async () => {
472+
seedStoredSession({ refreshToken: "existing-refresh-token" });
473+
oauthFlow.refreshToken.mockResolvedValue(
474+
mockTokenResponse({ refreshToken: null }),
475+
);
476+
stubAuthFetch();
477+
478+
await service.initialize();
479+
480+
expect(service.getState()).toMatchObject({
481+
status: "authenticated",
482+
sessionType: "persistent",
483+
});
484+
expect(sessionPort.getCurrent()?.refreshTokenEncrypted).toBe(
485+
"existing-refresh-token",
486+
);
487+
});
488+
315489
it("completes bootstrap but stays restoring when the stored-session restore hangs", async () => {
316490
vi.useFakeTimers();
317491
try {

0 commit comments

Comments
 (0)