[Fix] Token Store Overrides are now Respected (#1156)
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 migrations are backwards-compatible / 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 / 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 API Tests with external source of truth / build (22.x) (push) Has been cancelled
Lint & build / lint_and_build (latest) (push) Has been cancelled
Dev Environment Test With Custom Base Port / restart-dev-and-test-with-custom-base-port (push) Has been cancelled
Dev Environment Test / restart-dev-and-test (push) Has been cancelled
Run setup tests with custom base port / setup-tests-with-custom-base-port (push) Has been cancelled
Run setup tests / setup-tests (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
DB migrations are backwards-compatible / Test migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migrations are backwards-compatible / No migration changes (skipped) (push) Has been cancelled

### Context
Recently, a user raised [this
issue](https://github.com/stack-auth/stack-auth/issues/1144), which
indicated that `tokenOverrides` were not being respected/used in the
`getUser()` function. If we trace the flow through this function, we see
`this._getSession -> this._getOrCreateTokenStore -> _createCookieHelper
-> createCookieHelper -> createNextCookieHelper -> await rscHeaders()`.
What this means is that even when a `requestLike tokenOverride` was
passed, we would not end up using it because the `createCookieHelper`
call occurs before the extant override checking logic in
`getOrCreateTokenStore`, and the `createCookieHelper` didn't check the
override but only the default `tokenStoreInit`. This caused the error to
propagate up.

### Summary of Changes
We check the `tokenStoreOverride` in the `createCookieHelper` function
now, preventing this issue from happening. We also add extra test
coverage to verify that overrides are respected, and don't overwrite the
default token store.

### Out of Scope Discussion
The original issue was raised with a `bun` runtime running `next.js`
code. There seems to be some incompatibility between `bun 1.3.8` and
`nextjs 15+`, not just with our backend but with fetching and working
with responses from any `nextjs` server.
This commit is contained in:
Aman Ganapathy 2026-02-03 18:58:46 -08:00 committed by GitHub
parent 7a35751f8e
commit bb69ee4230
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 152 additions and 4 deletions

View File

@ -359,3 +359,151 @@ it("clientApp auth methods should match user auth methods", async ({ expect }) =
const userAuthHeaders = await user.getAuthHeaders();
expect(appAuthHeaders["x-stack-auth"]).toBe(userAuthHeaders["x-stack-auth"]);
});
// ============================================
// Request-like tokenStore override tests
// (Critical for Bun middleware compatibility - GitHub issue #1144)
// ============================================
/**
* Helper to build a cookie string for a request-like object.
*/
function buildCookieHeader(cookies: Record<string, string>): string {
return Object.entries(cookies)
.map(([name, value]) => `${name}=${encodeURIComponent(value)}`)
.join("; ");
}
it("getUser should work with request-like tokenStore containing auth cookies", async ({ expect }) => {
// Use nextjs-cookie as default to simulate real middleware scenario.
// This ensures the fix prevents rscHeaders() from being called when an override is provided.
const { serverApp, clientApp } = await createApp({}, {
server: { tokenStore: "nextjs-cookie" },
});
// Create two different users
const userAEmail = `${crypto.randomUUID()}@user-a.test`;
const userBEmail = `${crypto.randomUUID()}@user-b.test`;
const password = "test-password-123";
// Sign up User A
await clientApp.signUpWithCredential({
email: userAEmail,
password,
verificationCallbackUrl: "http://localhost:3000",
});
await clientApp.signInWithCredential({ email: userAEmail, password });
const userA = await clientApp.getUser({ or: "throw" });
const userATokens = await userA.currentSession.getTokens();
await clientApp.signOut();
// Sign up User B and keep them signed in on clientApp
await clientApp.signUpWithCredential({
email: userBEmail,
password,
verificationCallbackUrl: "http://localhost:3000",
});
await clientApp.signInWithCredential({ email: userBEmail, password });
const userB = await clientApp.getUser({ or: "throw" });
// Verify the two users are different
expect(userA.id).not.toBe(userB.id);
// Verify serverApp's default nextjs-cookie store would fail outside Next.js context.
// without passing tokenStore override rscHeaders() would be called and fail.
await expect(serverApp.getUser()).rejects.toThrow();
// Build cookies with User A's tokens (Option B - use different user's tokens)
const refreshCookieName = `stack-refresh-${serverApp.projectId}--default`;
const refreshCookieValue = JSON.stringify({
refresh_token: userATokens.refreshToken,
updated_at_millis: Date.now(),
});
const accessCookieValue = JSON.stringify([userATokens.refreshToken, userATokens.accessToken]);
const cookieHeader = buildCookieHeader({
[refreshCookieName]: refreshCookieValue,
"stack-access": accessCookieValue,
});
// Create a request-like object with User A's cookies
const requestLike = {
headers: new Headers({
cookie: cookieHeader,
}),
};
// Call getUser with the request-like tokenStore
// This MUST read from requestLike because:
// 1. serverApp's default store is empty
// 2. clientApp has User B signed in, not User A
// 3. Only requestLike contains User A's tokens
const serverUser = await serverApp.getUser({ tokenStore: requestLike });
expect(serverUser).not.toBeNull();
expect(serverUser!.id).toBe(userA.id); // Must be User A, not User B
expect(serverUser!.primaryEmail).toBe(userAEmail);
});
it("getUser should return null for request-like tokenStore with no auth cookies", async ({ expect }) => {
// Use nextjs-cookie as default to simulate real middleware scenario
const { serverApp } = await createApp({}, {
server: { tokenStore: "nextjs-cookie" },
});
// Create a request-like object with no auth cookies
const requestLike = {
headers: new Headers({
cookie: "",
}),
};
// Should return null, not throw
const serverUser = await serverApp.getUser({ tokenStore: requestLike });
expect(serverUser).toBeNull();
});
it("getUser should work with x-stack-auth header in request-like tokenStore", async ({ expect }) => {
const { serverApp, clientApp } = await createApp({});
await signIn(clientApp);
// Get the auth headers from the signed-in user
const authHeaders = await clientApp.getAuthHeaders();
// Create a request-like object with x-stack-auth header
const requestLike = {
headers: new Headers({
"x-stack-auth": authHeaders["x-stack-auth"],
}),
};
// Call getUser with the request-like tokenStore
const serverUser = await serverApp.getUser({ tokenStore: requestLike });
const clientUser = await clientApp.getUser({ or: "throw" });
expect(serverUser).not.toBeNull();
expect(serverUser!.primaryEmail).toBe("test@test.com");
expect(serverUser!.id).toBe(clientUser.id);
});
it("getUser with tokenStore override should not affect the app's default token store", async ({ expect }) => {
const { serverApp, clientApp } = await createApp({});
await signIn(clientApp);
const clientUser = await clientApp.getUser({ or: "throw" });
// Get user via serverApp with explicit tokenStore override
const tokens = await clientUser.currentSession.getTokens();
const serverUserWithOverride = await serverApp.getUser({
tokenStore: { accessToken: tokens.accessToken!, refreshToken: tokens.refreshToken! },
});
expect(serverUserWithOverride).not.toBeNull();
expect(serverUserWithOverride!.id).toBe(clientUser.id);
// serverApp's default token store (memory) should still be empty
// since we used an override, not the default
const serverUserDefault = await serverApp.getUser();
expect(serverUserDefault).toBeNull();
});

View File

@ -229,7 +229,6 @@ export class StackClientInterface {
refreshToken: null,
});
return await this._networkRetry(
() => this.sendClientRequestInner(path, requestOptions, session!, requestType),
session,

View File

@ -317,8 +317,9 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
private _anonymousSignUpInProgress: Promise<{ accessToken: string, refreshToken: string }> | null = null;
protected async _createCookieHelper(): Promise<CookieHelper> {
if (this._tokenStoreInit === 'nextjs-cookie' || this._tokenStoreInit === 'cookie') {
protected async _createCookieHelper(overrideTokenStoreInit?: TokenStoreInit): Promise<CookieHelper> {
const tokenStoreInit = overrideTokenStoreInit === undefined ? this._tokenStoreInit : overrideTokenStoreInit;
if (tokenStoreInit === 'nextjs-cookie' || tokenStoreInit === 'cookie') {
return await createCookieHelper();
} else {
return await createPlaceholderCookieHelper();
@ -877,7 +878,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
}
protected async _getSession(overrideTokenStoreInit?: TokenStoreInit): Promise<InternalSession> {
const tokenStore = this._getOrCreateTokenStore(await this._createCookieHelper(), overrideTokenStoreInit);
const tokenStore = this._getOrCreateTokenStore(await this._createCookieHelper(overrideTokenStoreInit), overrideTokenStoreInit);
const session = this._getSessionFromTokenStore(tokenStore);
return session;
}