@@ -79,16 +79,21 @@ const mockLogger: RootLogger = {
7979function 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