mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
## 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 -->
148 lines
6.4 KiB
TypeScript
148 lines
6.4 KiB
TypeScript
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);
|
|
});
|
|
});
|