stack/packages/stack-shared/src/sessions.ts
BilalG1 f7e389809e
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
feat(hexclave): PR 1 — wire compatibility layer (invisible) (#1475)
## Summary

**Stacked on #1468** (`docs/hexclave-rename-plan` — the plan doc). Diff
vs that base = the actual PR 1 code.

This is **PR 1 of the Hexclave rebrand: the invisible compatibility
layer**. Everything is additive. Old SDKs, old wire identifiers, and old
env var names keep working unchanged. The backend dual-accepts and
dual-emits; new SDK code emits `x-hexclave-*` headers and the
`hexclave_` Bearer prefix; cookies dual-write; env vars dual-read across
every category. **No user-visible rebranding lands here** — that's PR 2.

See [`RENAME-TO-HEXCLAVE.md`](./RENAME-TO-HEXCLAVE.md) → *"PR 1
implementation guide"* for the full per-work-area spec, file pointers,
and chosen approach.

## What's implemented (all 14 PR-1 work-areas)

- **SDK export aliases** — `Hexclave*` aliases for the user-facing
`Stack*` exports added in `packages/template`; codegen propagates them
to `@stackframe/{js,stack,react,tanstack-start}`. React-only aliases
correctly excluded from `@stackframe/js`. (`e60550a2`)
- **JWT issuer dual-accept** — `decodeAccessToken` accepts both
`api.stack-auth.com` and `api.hexclave.com` issuers. Signing unchanged.
(`fc781def`)
- **Request-header dual-accept** — backend + dashboard proxies normalize
`x-hexclave-*` → `x-stack-*` at the existing empty proxy hook (so
`smart-request.tsx` and every route schema keep working unchanged); CORS
allowlists extended via a derive-once helper. (`2a056eac`)
- **MCP `ask_hexclave`** — registered alongside `ask_stack_auth` via a
shared helper; `ask_stack_auth` behavior byte-identical. (`30ffd604`)
- **Dev-tool** — DOM ids + header emit switched.
`window.HexclaveDevTool` exposed alongside `window.StackDevTool`.
(`32131ea7`)
- **The big consolidated commit** (`7fed864a`):
- **Env vars** — central `getEnvVariable` prefix-transform (HEXCLAVE
first, STACK fallback); dashboard + template client env files dual-read;
`turbo.json` globalEnv; `NEXT_PUBLIC_STACK_PORT_PREFIX` renamed outright
across ~82 files including docker.
- **Cookies** — dual-write/dual-read auth (`stack-access`/`-refresh-*`
and custom-domain variants), OAuth-state
(`stack-oauth-{inner,outer}-*`), and low-risk cookies (`stack-is-https`,
`stack-last-seen-changelog-version`). Bypass sites patched (backend
OAuth callback, dashboard remote-dev auth route, impersonation snippets,
snapshot serializer).
- **Bearer prefix** — SDK token parser accepts both `stackauth_` and
`hexclave_`; emits `hexclave_`. Discovery correction: this is purely
SDK-internal — the backend never parses it.
- **Response headers** — backend dual-emits
`x-hexclave-{request-id,actual-status,known-error}`; SDKs dual-read (new
first, stack fallback).
- **SDK request-header emit switch** —
`client/server/admin-interface.ts` + dashboard `api-headers.ts` +
`internal-project-headers.ts` + `feedback-form.tsx` switched to
`x-hexclave-*`. Plus `stack_response_mode` query param.
- **Storage keys** — dev-tool / cli-auth / oauth-button / docs keys
renamed (straight); `stack:session-replay:v1` dual-read so in-progress
recordings survive SDK upgrades; `stack_mfa_attempt_code` dual-read.
- **Query params** — cross-domain params dual-emit/dual-accept via
shared helpers; backend `oauth/authorize` accepts
`hexclave_response_mode` and `stack_response_mode`; `stack-init-id`
renamed.
- **`Symbol.for`** — app-internals symbol gets a parallel
`Symbol.for("Hexclave--app-internals")` getter on each attach site (no
read-site churn — old symbol still attached). 3 file-private symbols
renamed outright.
- **Config discovery** — prefer `hexclave.config.ts`, fall back to
`stack.config.ts` at every discovery site (CLI / dashboard / backend /
local-emulator); `init` writes the new filename; CLI credentials path
migrates.
- **Internal renames** — `StackAssertionError`,
`StackClient/Server/AdminInterface` renamed outright (no alias, per the
"internal-only → rename" rule). ~264 files touched.
- **Review-pass fixes** (`21217fbe`) — three real bugs found by parallel
review agents and fixed:
- `snapshot-serializer.ts` was interpolating the whole
`keyedCookieNamePrefixes` array (`${arr}`) — adding a second prefix
would have corrupted **every** OAuth-cookie snapshot, not just new ones.
- **Docker port-prefix producer/consumer mismatch** —
`entrypoint.sh`/`run-emulator.sh`/cloud-init `user-data` were still
producing `NEXT_PUBLIC_STACK_PORT_PREFIX` while the dashboard sentinel +
consumers had been renamed; silent self-host regression (custom port
prefix would be ignored).
- **Missing `hexclave-oauth-inner-*` dual-write** in the OAuth authorize
route — callback's fallback masked it but the dual-write was specified
by the plan.
- Plus: `mcp.test.ts` tool-list assertions updated to include
`ask_hexclave`; two dashboard header-emit sites switched to
`x-hexclave-*` for consistency.
- **E2E snapshot serializer follow-up** (`4b16cc5d`) —
`x-hexclave-request-id` added to the hidden-headers list (mirroring
`x-stack-request-id` treatment), and 2 sample inline snapshots
regenerated in `projects.test.ts` to include the new dual-emitted
headers.

## Verification

- **`pnpm typecheck`** — clean (the fresh-worktree `@/.source` / Prisma
codegen gap in `stack-docs` is pre-existing and unrelated).
- **`pnpm lint`** — 29/29 packages green.
- **`pnpm exec turbo run build --filter=./packages/*`** — 13/13 packages
build (including `@stackframe/stack-cli` once the dashboard standalone
is present).
- **Live E2E** against a running backend on `cl/hexclave-pr1`:
- `pnpm test run
apps/e2e/tests/backend/endpoints/api/v1/internal/mcp.test.ts` — **6/6
pass** (verifies the new `ask_hexclave` tool — the hand-written inline
snapshot matched actual MCP server output).
- `pnpm test run
apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts` —
**11/11 pass** (verifies wire dual-accept + dual-emit end-to-end; the
snapshot serializer fix was found and applied during this check).

A four-agent parallel **review pass** also audited the full diff for
logic/runtime bugs across the work-areas (wire headers + JWT, cookies +
bearer + symbols, env vars, query params + config + MCP + aliases). All
in-slice review verdicts were ✓ except the three bugs listed above,
which are now fixed.

## Known follow-ups (out of scope for this PR)

- **E2E snapshots across the rest of the suite** — backend now
dual-emits `x-hexclave-{known-error,actual-status}` alongside
`x-stack-*`, which legitimately appears in inline snapshots throughout
`apps/e2e`. Two were regenerated here as a sample; the rest should regen
with `vitest -u` in CI.
- **Docker shell env vars beyond `PORT_PREFIX`** — `entrypoint.sh` still
reads `STACK_*` env vars directly (the JS-side `getEnvVariable`
transform doesn't help the shell). JS consumers dual-read so it works in
practice; full shell-level dual-read is a deeper self-host follow-up.
- **`@stackframe/stack-cli` build ordering** — pre-existing; needs
`build:rde-standalone` first. Not affected by this PR.

## Test plan

- [ ] CI runs full e2e suite (with `vitest -u` to absorb dual-emit
snapshot deltas, then committed back)
- [ ] Spot-check: an old SDK build (emitting only `x-stack-*`) still
authenticates against the new backend
- [ ] Spot-check: a new SDK (emitting `x-hexclave-*` / `Bearer
hexclave_*`) still authenticates against an old backend during deploy
ordering
- [ ] Manual: `npx @stackframe/stack-cli@latest init` (new onboarding
entrypoint) generates `hexclave.config.ts`
- [ ] Manual: existing `stack.config.ts`-only project still resolves (no
migration required)

---------

Co-authored-by: bilal <bilal@stack-auth.com>
2026-05-23 17:24:55 -07:00

311 lines
13 KiB
TypeScript

import * as jose from 'jose';
import { InferType } from 'yup';
import { accessTokenPayloadSchema } from './schema-fields';
import { HexclaveAssertionError, throwErr } from "./utils/errors";
import { runAsynchronously, wait } from './utils/promises';
import { Store } from "./utils/stores";
export type AccessTokenPayload = InferType<typeof accessTokenPayloadSchema>;
function decodeAccessTokenIfValid(token: string): AccessTokenPayload | null {
try {
const payload = jose.decodeJwt(token);
return accessTokenPayloadSchema.validateSync(payload);
} catch (e) {
return null;
}
}
export class AccessToken {
static createIfValid(token: string): AccessToken | null {
const payload = decodeAccessTokenIfValid(token);
if (!payload) return null;
return new AccessToken(token);
}
private constructor(
public readonly token: string,
) {
if (token === "undefined") {
throw new HexclaveAssertionError("Access token is the string 'undefined'; it's unlikely this is the correct value. They're supposed to be unguessable!");
}
}
get payload() {
return decodeAccessTokenIfValid(this.token) ?? throwErr("Invalid access token in payload (should've been validated in createIfValid)", { token: this.token });
}
get expiresAt(): Date {
const { exp } = this.payload;
if (exp === undefined) return new Date(8640000000000000); // max date value
return new Date(exp * 1000);
}
get issuedAt(): Date {
const { iat } = this.payload;
return new Date(iat * 1000);
}
/**
* @returns The number of milliseconds until the access token expires, or 0 if it has already expired.
*/
get expiresInMillis(): number {
return Math.max(0, this.expiresAt.getTime() - Date.now());
}
get issuedMillisAgo(): number {
return Math.max(0, Date.now() - this.issuedAt.getTime());
}
isExpired(): boolean {
return this.expiresInMillis <= 0;
}
}
export class RefreshToken {
constructor(
public readonly token: string,
) {
if (token === "undefined") {
throw new HexclaveAssertionError("Refresh token is the string 'undefined'; it's unlikely this is the correct value. They're supposed to be unguessable!");
}
}
}
/**
* An InternalSession represents a user's session, which may or may not be valid. It may contain an access token, a refresh token, or both.
*
* A session never changes which user or session it belongs to, but the tokens in it may change over time.
*/
export class InternalSession {
/**
* Each session has a session key that depends on the tokens inside. If the session has a refresh token, the session key depends only on the refresh token. If the session does not have a refresh token, the session key depends only on the access token.
*
* Multiple Session objects may have the same session key, which implies that they represent the same session by the same user. Furthermore, a session's key never changes over the lifetime of a session object.
*
* This is useful for caching and indexing sessions.
*/
public readonly sessionKey: string;
/**
* An access token that is not known to be invalid (ie. may be valid, but may have expired).
*/
private _accessToken: Store<AccessToken | null>;
private readonly _refreshToken: RefreshToken | null;
/**
* Whether the session as a whole is known to be invalid (ie. both access and refresh tokens are invalid). Used as a cache to avoid making multiple requests to the server (sessions never go back to being valid after being invalidated).
*
* It is possible for the access token to be invalid but the refresh token to be valid, in which case the session is
* still valid (just needs a refresh). It is also possible for the access token to be valid but the refresh token to
* be invalid, in which case the session is also valid (eg. if the refresh token is null because the user only passed
* in an access token, eg. in a server-side request handler).
*/
private _knownToBeInvalid = new Store<boolean>(false);
private _refreshPromise: Promise<AccessToken | null> | null = null;
constructor(private readonly _options: {
refreshAccessTokenCallback(refreshToken: RefreshToken): Promise<AccessToken | null>,
refreshToken: string | null,
accessToken?: string | null,
}) {
this._accessToken = new Store(_options.accessToken ? AccessToken.createIfValid(_options.accessToken) : null);
this._refreshToken = _options.refreshToken ? new RefreshToken(_options.refreshToken) : null;
if (_options.accessToken === null && _options.refreshToken === null) {
// this session is already invalid
this._knownToBeInvalid.set(true);
}
this.sessionKey = InternalSession.calculateSessionKey({ accessToken: _options.accessToken ?? null, refreshToken: _options.refreshToken });
}
static calculateSessionKey(ofTokens: { refreshToken: string | null, accessToken?: string | null }): string {
if (ofTokens.refreshToken) {
return `refresh-${ofTokens.refreshToken}`;
} else if (ofTokens.accessToken) {
return `access-${ofTokens.accessToken}`;
} else {
return "not-logged-in";
}
}
isKnownToBeInvalid() {
return this._knownToBeInvalid.get();
}
/**
* Marks the session object as invalid, meaning that the refresh and access tokens can no longer be used. There is no
* way out of this state, and the session object will never return valid tokens again.
*/
markInvalid() {
this._accessToken.set(null);
this._knownToBeInvalid.set(true);
}
onInvalidate(callback: () => void): { unsubscribe: () => void } {
return this._knownToBeInvalid.onChange(() => callback());
}
getRefreshToken(): RefreshToken | null {
if (this.isKnownToBeInvalid()) return null;
return this._refreshToken;
}
/**
* Returns the access token if it is found in the cache and not expired yet, or null otherwise. Never fetches new tokens.
*/
getAccessTokenIfNotExpiredYet(minMillisUntilExpiration: number, maxMillisSinceIssued: number | null): AccessToken | null {
if (minMillisUntilExpiration > 45_000) {
throw new Error(`Required access token expiry ${minMillisUntilExpiration}ms is too long; access tokens are too short to be used for more than 45s`);
}
if (maxMillisSinceIssued !== null && maxMillisSinceIssued < 15_000) {
throw new Error(`Required access token issuance ${maxMillisSinceIssued}ms is too short; assume that access token generation can take at least 15s`);
}
const accessToken = this._getPotentiallyInvalidAccessTokenIfAvailable();
if (!accessToken || accessToken.expiresInMillis < minMillisUntilExpiration) return null;
if (maxMillisSinceIssued !== null && accessToken.issuedMillisAgo > maxMillisSinceIssued) return null;
return accessToken;
}
/**
* Returns the access token if it is found in the cache, fetching it otherwise.
*
* This is usually the function you want to call to get an access token. Either set `minMillisUntilExpiration` to a reasonable value, or catch errors that occur if it expires, and call `markAccessTokenExpired` to mark the token as expired if so (after which a call to this function will always refetch the token).
*
* @returns null if the session is known to be invalid, cached tokens if they exist in the cache and the access token hasn't expired yet (the refresh token might still be invalid), or new tokens otherwise.
*/
async getOrFetchLikelyValidTokens(minMillisUntilExpiration: number, maxMillisSinceIssued: number | null): Promise<{ accessToken: AccessToken, refreshToken: RefreshToken | null } | null> {
// fast path to save a roundtrip to the server if the session is known to be invalid
if (this.isKnownToBeInvalid()) return null;
const accessToken = this.getAccessTokenIfNotExpiredYet(minMillisUntilExpiration, maxMillisSinceIssued);
if (!accessToken) {
const newTokens = await this.fetchNewTokens();
const expiresInMillis = newTokens?.accessToken.expiresInMillis;
const issuedMillisAgo = newTokens?.accessToken.issuedMillisAgo;
if (expiresInMillis !== undefined && expiresInMillis < minMillisUntilExpiration) {
throw new HexclaveAssertionError(`Required access token expiry ${minMillisUntilExpiration}ms is too long; access tokens are too short when they're generated (${expiresInMillis}ms)`);
}
if (maxMillisSinceIssued !== null && issuedMillisAgo !== undefined && issuedMillisAgo > maxMillisSinceIssued) {
throw new HexclaveAssertionError(`Required access token issuance ${maxMillisSinceIssued}ms is too short; access token issuance is too slow (${issuedMillisAgo}ms)`);
}
return newTokens;
}
return { accessToken, refreshToken: this.getRefreshToken() };
}
/**
* Fetches new tokens that are, at the time of fetching, guaranteed to be valid.
*
* The newly generated tokens are short-lived, so it's good practice not to rely on their validity (if possible). However, this function is useful in some cases where you only want to pass access tokens to a service, and you want to make sure said access token has the longest possible lifetime.
*
* In most cases, you should prefer `getOrFetchLikelyValidTokens`.
*
* @returns null if the session is known to be invalid, or new tokens otherwise (which, at the time of fetching, are guaranteed to be valid).
*/
async fetchNewTokens(): Promise<{ accessToken: AccessToken, refreshToken: RefreshToken | null } | null> {
const accessToken = await this._getNewlyFetchedAccessToken();
return accessToken ? { accessToken, refreshToken: this._refreshToken } : null;
}
/**
* Manually mark the access token as expired, even if the date on its payload may still be valid.
*
* You don't usually have to call this function anymore, but you may want to call suggestAccessTokenExpired
* to hint that the access token should be refreshed as its data may have changed, if possible.
*/
markAccessTokenExpired(accessToken?: AccessToken) {
if (!accessToken || this._accessToken.get()?.token === accessToken.token) {
this._accessToken.set(null);
}
}
/**
* Strongly suggests that the access token should be refreshed as its data may have changed, although it's up to this
* implementation to decide whether or when the access token will be refreshed.
*
* This is particularly useful when the data associated with the access token may have changed for example due to an
* update to the user's profile.
*
* The current implementation marks the access token as expired if and only if a refresh token is available (regardless of
* whether the refresh token is actually valid or not), although this is not a guarantee and subject to change.
*
* If you need a stronger guarantee of revoking an access token, use markAccessTokenExpired instead.
*/
suggestAccessTokenExpired(): void {
if (this._refreshToken) {
this.markAccessTokenExpired();
}
}
startRefreshingAccessToken(minMillisUntilExpiration: number, maxMillisSinceIssued: number | null): { unsubscribe: () => void } {
let canceled = false;
runAsynchronously(async () => {
while (!canceled) {
const tokens = await this.getOrFetchLikelyValidTokens(minMillisUntilExpiration, maxMillisSinceIssued);
if (!tokens) return; // session is invalid, stop refreshing
const nextRefreshIn = Math.min(
tokens.accessToken.expiresInMillis - minMillisUntilExpiration,
(maxMillisSinceIssued ?? Infinity) - tokens.accessToken.issuedMillisAgo,
);
await wait(Math.max(1, nextRefreshIn));
}
});
return {
unsubscribe: () => {
canceled = true;
},
};
}
/**
* Note that a callback invocation with `null` does not mean the session has been invalidated; the access token may just have expired. Use `onInvalidate` to detect invalidation.
*/
onAccessTokenChange(callback: (newAccessToken: AccessToken | null) => void): { unsubscribe: () => void } {
return this._accessToken.onChange(callback);
}
/**
* @returns An access token, which may be expired or expire soon, or null if it is known to be invalid.
*/
private _getPotentiallyInvalidAccessTokenIfAvailable(): AccessToken | null {
if (this.isKnownToBeInvalid()) return null;
const accessToken = this._accessToken.get();
if (accessToken && !accessToken.isExpired()) return accessToken;
return null;
}
/**
* You should prefer `_getOrFetchPotentiallyInvalidAccessToken` in almost all cases.
*
* @returns A newly fetched access token (never read from cache), or null if the session either does not represent a user or the session is invalid.
*/
private async _getNewlyFetchedAccessToken(): Promise<AccessToken | null> {
if (!this._refreshToken) return null;
if (this._knownToBeInvalid.get()) return null;
if (!this._refreshPromise) {
this._refreshAndSetRefreshPromise(this._refreshToken);
}
return await this._refreshPromise;
}
private _refreshAndSetRefreshPromise(refreshToken: RefreshToken) {
let refreshPromise: Promise<AccessToken | null> = this._options.refreshAccessTokenCallback(refreshToken).then((accessToken) => {
if (refreshPromise === this._refreshPromise) {
this._refreshPromise = null;
this._accessToken.set(accessToken);
if (!accessToken) {
this.markInvalid();
}
}
return accessToken;
});
this._refreshPromise = refreshPromise;
}
}