From 76fc62e98b8c41ba9f5b55c334bc71e6759d4b5b Mon Sep 17 00:00:00 2001 From: BilalG1 Date: Wed, 10 Jun 2026 11:13:33 -0700 Subject: [PATCH] fix(rde): stop the RDE dashboard blanking on every access-token refresh (#1566) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem In the Remote Development Environment (`hexclave dev`), the dashboard suspends and goes **blank for a moment every ~30–60 seconds**, then repopulates. It never happens on a plain `pnpm dev` dashboard. ## Root cause The RDE auth gate ([`remote-development-environment-auth-gate.tsx`](../blob/dev/apps/dashboard/src/app/remote-development-environment-auth-gate.tsx)) keeps the browser signed in by re-installing a freshly minted **access-token-only** session (`signInWithTokens({ accessToken, refreshToken: "" })`) on a timer (capped by `RDE_ACCESS_TOKEN_MAX_AGE_MS`). `InternalSession.calculateSessionKey` keyed access-only sessions by the **access-token string**: ```ts } else if (ofTokens.accessToken) { return `access-${ofTokens.accessToken}`; // 👈 changes on every refresh } ``` So each refresh = a new key = a brand-new `InternalSession` object. Every session-scoped cache (`useUser` / `useConfig` / `useTeams` / `useOwnedProjects`, via `createCacheBySession`) is keyed by the **session object**, so a new object means a cold cache → pending promise → `React.use()` suspends → the whole tree falls back to its (empty) Suspense boundary. It only *shows* in RDE because the backend is remote: the post-swap refetch has real network latency, so the blank is visible for hundreds of ms. On localhost the same swap is a sub-frame flicker. (A background `refresh()`/write-only is stale-preserving and does **not** suspend — only a new session dependency does.) ## Fix Two changes in the SDK source (`packages/shared` + `packages/template`; the `js`/`next`/`react`/`tanstack-start` copies are generated): 1. **Stable key for access-only sessions.** Key by the token's `refresh_token_id` (decoded from the JWT) instead of the raw token string. Every access token minted for the same session shares that id, so the session identity — and therefore every cache — stays stable across refreshes. Falls back to the raw token if the JWT can't be decoded. Refresh-keyed and not-logged-in paths are untouched. 2. **In-place token update.** New `InternalSession.updateAccessToken(token)`, called from `_signInToAccountWithTokens`, pushes the fresh token into the reused session object instead of constructing a new one (no-op when the session is invalid / the token is unchanged / null). Net effect: re-minting the RDE access token reuses the same `InternalSession`, caches stay warm, nothing suspends, no blank. ## Why this is safe - Session reuse is scoped **per token store** — `_sessionsByTokenStoreAndSessionKey` is a `WeakMap` keyed by the token-store object first, then the session key — so server-side per-request sessions remain isolated regardless of how coarse the key is. No cross-user/cross-session mixup. - `refresh_token_id` is a per-session UUID; the coarser key only merges *the same session's* successive access tokens, which is the intent. - This keying convention matches the existing `refresh-${refreshToken}` path (key by the unique token value). ## Testing - **Reproduced live** via a temporary harness driven through a real browser: forcing the session-identity swap triggered the exact cold-cache refetch storm (`useUser`/`useTeams`/`useOwnedProjects` refetching) that produces the blank. - **Unit-verified** the new behavior against the built `shared` dist: two access tokens sharing a `refresh_token_id` → same session key; different ids → different keys; opaque token → fallback; refresh-keyed unchanged; `updateAccessToken` swaps the token in place and is a correct no-op when invalid/unchanged/null. - **Typecheck + lint clean** across `shared`, `template`, `next`, `react`, `tanstack-start`, and the dashboard. - Reviewed by independent passes for correctness, security/blast-radius, and simplification — no actionable findings. ## How to verify in the real RDE path Set `RDE_ACCESS_TOKEN_MAX_AGE_MS` to e.g. `5000` in the auth gate and run `hexclave dev`: before this change the dashboard blanks every few seconds; after, it stays populated. --- ## Summary by cubic Stops the RDE dashboard from going blank every 30–60s by keeping session identity stable during access‑token refreshes. Session-scoped caches stay warm, so the UI no longer suspends. - **Bug Fixes** - Key access-only sessions by the JWT `refresh_token_id` (stable across re‑mints); fall back to the raw token if undecodable. Implemented in `packages/shared`; tests in `packages/shared/src/sessions.test.ts`. - Add `InternalSession.updateAccessToken()` and use it in `_signInToAccountWithTokens` to update the token in place only when the incoming pair resolves to the same `sessionKey` (rejects foreign/null/unchanged/undecodable; covers access‑only and refresh‑backed sessions). Prefetch the current user via `runAsynchronously` in write‑only mode. Implemented in `packages/shared` and `packages/template`. Written for commit fdaf2f28be6fd9a0e68daca404c29a55fc3a533c. Summary will update on new commits. Review in cubic ## Summary by CodeRabbit ## Release Notes * **Bug Fixes** * Improved session stability and token management to enhance authentication reliability. * Strengthened session validation to prevent stale token-related issues. * **Tests** * Added comprehensive test coverage for session and token handling mechanisms. --- packages/shared/src/sessions.test.ts | 147 ++++++++++++++++++ packages/shared/src/sessions.ts | 26 ++++ .../apps/implementations/client-app-impl.ts | 12 +- 3 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 packages/shared/src/sessions.test.ts diff --git a/packages/shared/src/sessions.test.ts b/packages/shared/src/sessions.test.ts new file mode 100644 index 000000000..190eb691b --- /dev/null +++ b/packages/shared/src/sessions.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { InternalSession } from "./sessions"; + +/** + * Builds a decodable (unsigned) access-token JWT with a valid payload. `refreshTokenId` controls the + * `refresh_token_id` claim (the session identifier); `iatOffsetSeconds` lets two tokens for the same session + * differ as strings while sharing a `refresh_token_id`. + */ +function createAccessTokenString(refreshTokenId: string, options?: { iatOffsetSeconds?: number, sub?: string }): string { + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + const nowSeconds = Math.floor(Date.now() / 1000) + (options?.iatOffsetSeconds ?? 0); + return [ + encode({ alg: "none", typ: "JWT" }), + encode({ + sub: options?.sub ?? "user-id", + exp: nowSeconds + 60, + iat: nowSeconds, + iss: "https://api.example.test", + aud: "project-id", + project_id: "project-id", + branch_id: "main", + refresh_token_id: refreshTokenId, + role: "authenticated", + name: null, + email: null, + email_verified: false, + selected_team_id: null, + signed_up_at: nowSeconds, + is_anonymous: false, + is_restricted: false, + restricted_reason: null, + requires_totp_mfa: false, + }), + "", + ].join("."); +} + +function createAccessOnlySession(accessToken: string): InternalSession { + return new InternalSession({ + refreshAccessTokenCallback: async () => null, + refreshToken: null, + accessToken, + }); +} + +const currentToken = (session: InternalSession) => session.getAccessTokenIfNotExpiredYet(20_000, null)?.token; + +describe("InternalSession.calculateSessionKey", () => { + it("keys by the refresh token when one is present (ignoring any access token)", () => { + expect(InternalSession.calculateSessionKey({ refreshToken: "rt-abc" })).toBe("refresh-rt-abc"); + expect(InternalSession.calculateSessionKey({ refreshToken: "rt-abc", accessToken: createAccessTokenString("rtid-1") })) + .toBe("refresh-rt-abc"); + }); + + it("returns not-logged-in when neither token is present", () => { + expect(InternalSession.calculateSessionKey({ refreshToken: null })).toBe("not-logged-in"); + expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: null })).toBe("not-logged-in"); + }); + + it("keys an access-only session by its refresh_token_id", () => { + expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: createAccessTokenString("rtid-1") })) + .toBe("access-session-rtid-1"); + }); + + it("is stable across re-minted access tokens for the same session (the regression this fixes)", () => { + const first = createAccessTokenString("rtid-1", { iatOffsetSeconds: 0 }); + const second = createAccessTokenString("rtid-1", { iatOffsetSeconds: 1 }); + expect(second).not.toBe(first); + expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: second })) + .toBe(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: first })); + }); + + it("distinguishes access-only sessions with different refresh_token_ids", () => { + expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: createAccessTokenString("rtid-1") })) + .not.toBe(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: createAccessTokenString("rtid-2") })); + }); + + it("falls back to the raw token when the access token can't be decoded", () => { + expect(InternalSession.calculateSessionKey({ refreshToken: null, accessToken: "not-a-jwt" })).toBe("access-not-a-jwt"); + }); +}); + +describe("InternalSession#updateAccessToken", () => { + it("installs a fresh token for the same access-only session in place", () => { + const initial = createAccessTokenString("rtid-1", { iatOffsetSeconds: 0 }); + const refreshed = createAccessTokenString("rtid-1", { iatOffsetSeconds: 1 }); + const session = createAccessOnlySession(initial); + + session.updateAccessToken({ accessToken: refreshed, refreshToken: null }); + expect(currentToken(session)).toBe(refreshed); + // identity is unchanged — same session key, same object + expect(session.sessionKey).toBe("access-session-rtid-1"); + }); + + it("rejects a token pair belonging to a different access-only session", () => { + const initial = createAccessTokenString("rtid-1"); + const foreign = createAccessTokenString("rtid-2", { sub: "other-user" }); + const session = createAccessOnlySession(initial); + + session.updateAccessToken({ accessToken: foreign, refreshToken: null }); + expect(currentToken(session)).toBe(initial); + }); + + it("is a no-op for an unchanged, null, or undecodable token", () => { + const initial = createAccessTokenString("rtid-1"); + const session = createAccessOnlySession(initial); + + session.updateAccessToken({ accessToken: initial, refreshToken: null }); + session.updateAccessToken({ accessToken: null, refreshToken: null }); + session.updateAccessToken({ accessToken: "not-a-jwt", refreshToken: null }); + expect(currentToken(session)).toBe(initial); + }); + + it("never revives an invalidated session", () => { + const session = createAccessOnlySession(createAccessTokenString("rtid-1")); + session.markInvalid(); + + session.updateAccessToken({ accessToken: createAccessTokenString("rtid-1", { iatOffsetSeconds: 1 }), refreshToken: null }); + expect(session.isKnownToBeInvalid()).toBe(true); + expect(currentToken(session)).toBeUndefined(); + }); + + it("updates a refresh-token-backed session's access token in place when the refresh token matches", () => { + const session = new InternalSession({ + refreshAccessTokenCallback: async () => null, + refreshToken: "rt-abc", + accessToken: createAccessTokenString("rtid-1"), + }); + const refreshed = createAccessTokenString("rtid-2", { iatOffsetSeconds: 1 }); + + session.updateAccessToken({ accessToken: refreshed, refreshToken: "rt-abc" }); + expect(currentToken(session)).toBe(refreshed); + expect(session.sessionKey).toBe("refresh-rt-abc"); + }); + + it("rejects a token pair carrying a different refresh token for a refresh-backed session", () => { + const initial = createAccessTokenString("rtid-1"); + const session = new InternalSession({ + refreshAccessTokenCallback: async () => null, + refreshToken: "rt-abc", + accessToken: initial, + }); + + session.updateAccessToken({ accessToken: createAccessTokenString("rtid-2"), refreshToken: "rt-other" }); + expect(currentToken(session)).toBe(initial); + }); +}); diff --git a/packages/shared/src/sessions.ts b/packages/shared/src/sessions.ts index e89e08776..d6744e2ed 100644 --- a/packages/shared/src/sessions.ts +++ b/packages/shared/src/sessions.ts @@ -124,6 +124,14 @@ export class InternalSession { if (ofTokens.refreshToken) { return `refresh-${ofTokens.refreshToken}`; } else if (ofTokens.accessToken) { + // Access-only sessions (no refresh token) are keyed by the underlying session's `refresh_token_id`, not the + // access token string: access tokens get re-minted frequently, and keying by the raw token would spawn a new + // session (and cold-invalidate every session-scoped cache) on each refresh. Falls back to the raw token if + // the JWT can't be decoded. + const refreshTokenId = decodeAccessTokenIfValid(ofTokens.accessToken)?.refresh_token_id; + if (refreshTokenId) { + return `access-session-${refreshTokenId}`; + } return `access-${ofTokens.accessToken}`; } else { return "not-logged-in"; @@ -210,6 +218,24 @@ export class InternalSession { return accessToken ? { accessToken, refreshToken: this._refreshToken } : null; } + /** + * Installs a freshly obtained token pair's access token into this session in place, keeping the session object + * (and therefore every session-scoped cache) stable instead of constructing a new InternalSession. No-op if the + * session is invalid, the access token can't be decoded, it's unchanged, or the pair doesn't map to this session + * (so a foreign token can never be written into this object's cache); never clears an existing token. + */ + updateAccessToken(tokens: { accessToken: string | null, refreshToken: string | null }) { + if (this._knownToBeInvalid.get()) return; + if (!tokens.accessToken) return; + const newAccessToken = AccessToken.createIfValid(tokens.accessToken); + if (!newAccessToken) return; + // Self-enforce the "a session never changes which session it belongs to" invariant: only install a token pair + // that maps to this same session key (validated against the incoming pair, not this session's existing tokens). + if (InternalSession.calculateSessionKey(tokens) !== this.sessionKey) return; + if (this._accessToken.get()?.token === newAccessToken.token) return; + this._accessToken.set(newAccessToken); + } + /** * Manually mark the access token as expired, even if the date on its payload may still be valid. * diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts index 49fd65643..b26d68011 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts @@ -1547,10 +1547,14 @@ export class _HexclaveClientAppImplIncomplete {}); + // If these tokens resolve to a session we already have (eg. the RDE dashboard re-installing a freshly minted + // access token for the same access-only session), push the new token into it in place; constructing a new + // session here would cold-invalidate every session-scoped cache and suspend the UI on each refresh. + const session = this._getSessionFromTokenStore(tokenStore); + session.updateAccessToken(tokens); + + // Pre-fetch the current user so the cache is warm when useUser() re-renders (write-only, so it never suspends). + runAsynchronously(this._currentUserCache.getOrWait([session], "write-only")); } protected _getTokenStoreInitForFreshTokens(tokens: { accessToken: string | null, refreshToken: string }): TokenStoreInit | undefined {