mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-19 21:00:40 +08:00
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:
parent
0fb0e2d10d
commit
76fc62e98b
147
packages/shared/src/sessions.test.ts
Normal file
147
packages/shared/src/sessions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user