fix(rde): stop the RDE dashboard blanking on every access-token refresh (#1566)

## 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.

<!-- This is an auto-generated description by cubic. -->
---
## 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`.

<sup>Written for commit fdaf2f28be.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1566?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
BilalG1 2026-06-10 11:13:33 -07:00 committed by GitHub
parent 0fb0e2d10d
commit 76fc62e98b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 181 additions and 4 deletions

View File

@ -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);
});
});

View File

@ -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.
*

View File

@ -1547,10 +1547,14 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
const tokenStore = this._getOrCreateTokenStore(await this._createCookieHelper());
tokenStore.set(tokens);
// Pre-fetch the current user for the new session so the cache is already
// populated when useUser() re-renders, avoiding a stale-cache render cycle.
const newSession = this._getSessionFromTokenStore(tokenStore);
this._currentUserCache.getOrWait([newSession], "write-only").catch(() => {});
// 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 {