diff --git a/.claude/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md index 4ae98fc1e..7712fe4e7 100644 --- a/.claude/CLAUDE-KNOWLEDGE.md +++ b/.claude/CLAUDE-KNOWLEDGE.md @@ -521,5 +521,14 @@ A: Only auto-start hosted OAuth callback handling when the current URL has `code ## Q: Should built-with hosted handler domains be manually configured as trusted domains? A: No. Treat the hosted handler origin for the project, such as `https://.built-with-stack-auth.com` or the origin derived from `NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE`, as an implicit trusted redirect domain on both client and backend validation paths. The hosted template must put `{projectId}` in the hostname so every project has its own origin; path-based templates like `https://host/{projectId}/{hostedPath}` are not safe for implicit origin trust. +## Q: How should post-auth redirects mint cross-domain auth codes right after sign-in? +A: When a sign-in/sign-up/OAuth callback immediately redirects through the cross-domain authorize endpoint, pass the freshly returned `{ accessToken, refreshToken }` as an override token store into the redirect path. Do not rely on browser cookies having already flushed; otherwise the request can pair a new access token with an old refresh token and fail the backend refresh-token/session consistency check. + +## Q: How should mixed-token cross-domain auth regressions be tested? +A: Add a source-level template test that creates a client app with a stale persistent token store, calls `_createCrossDomainAuthRedirectUrl` with `overrideTokenStoreInit`, and patches only `sendClientRequest` on the real client interface so session creation remains intact. The assertion should inspect the session passed to the interface and require the fresh refresh token, proving the cross-domain authorize request does not use stale persisted tokens. + +## Q: When should cross-domain auth call `captureError`? +A: Do not call `captureError` for normal cross-domain auth failures such as stale/deleted cookies, untrusted redirect URLs, invalid or mismatched refresh tokens, missing handoff params, or interrupted auth flows. Reserve `captureError` for states that definitely imply a Stack developer mistake; ordinary auth failures should just return or throw their normal user-facing errors. + ## Q: How should the npm publish workflow create the post-publish dev version bump? A: The workflow needs a full checkout using the fine-grained `NPM_PUBLISH_VERSION_UPDATE_PR_PAT` secret. It then fetches `origin/dev`, checks out `dev`, creates a non-interactive patch changeset, runs `pnpm changeset version`, copies the generated `packages/template/package.json` version line back into `packages/template/package-template.json`, and commit/pushes `chore: update package versions`. Because direct pushes to `dev` are blocked by repository rules requiring PRs and the `all-good` status check, the PAT's owning user or bot account must be added to the ruleset bypass list with "Always allow" rather than "For pull requests only". diff --git a/apps/e2e/tests/js/cross-domain-auth.test.ts b/apps/e2e/tests/js/cross-domain-auth.test.ts index 40410c468..9c4ccc149 100644 --- a/apps/e2e/tests/js/cross-domain-auth.test.ts +++ b/apps/e2e/tests/js/cross-domain-auth.test.ts @@ -244,7 +244,10 @@ it("does not await pending auth resolutions when post-callback redirect mints a await expect((clientApp as any)._redirectToHandler( "afterSignIn", { replace: true }, - { awaitPendingAuthResolutions: false }, + { + awaitPendingAuthResolutions: false, + overrideTokenStoreInit: { accessToken: "fresh-access-token", refreshToken: "fresh-refresh-token" }, + }, )).rejects.toThrowError("INTENTIONAL_TEST_ABORT"); } finally { globalThis.window = previousWindow; @@ -253,6 +256,7 @@ it("does not await pending auth resolutions when post-callback redirect mints a expect(createCrossDomainAuthRedirectUrlSpy).toHaveBeenCalledWith(expect.objectContaining({ awaitPendingAuthResolutions: false, + overrideTokenStoreInit: { accessToken: "fresh-access-token", refreshToken: "fresh-refresh-token" }, })); }); }); diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.cross-domain.test.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.cross-domain.test.ts new file mode 100644 index 000000000..f947ae95f --- /dev/null +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.cross-domain.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { StackClientApp } from "../interfaces/client-app"; + +describe("StackClientApp cross-domain auth", () => { + it("uses the fresh post-auth refresh token when minting a cross-domain handoff", async () => { + const clientApp = new StackClientApp({ + baseUrl: "http://localhost:12345", + projectId: "00000000-0000-4000-8000-000000000000", + publishableClientKey: "stack-pk-test", + tokenStore: { + accessToken: "stale-access-token", + refreshToken: "stale-refresh-token", + }, + redirectMethod: "none", + noAutomaticPrefetch: true, + }); + + const clientInterface = Reflect.get(clientApp, "_interface"); + const originalSendClientRequest = Reflect.get(clientInterface, "sendClientRequest"); + const capturedRefreshTokens: string[] = []; + + Reflect.set(clientInterface, "sendClientRequest", async (_path: unknown, _requestOptions: unknown, session: unknown) => { + const getRefreshToken = Reflect.get(session ?? {}, "getRefreshToken"); + if (typeof getRefreshToken !== "function") { + throw new Error("Expected cross-domain auth to pass a session to the client interface."); + } + const refreshToken = getRefreshToken.call(session); + const refreshTokenString = Reflect.get(refreshToken ?? {}, "token"); + if (typeof refreshTokenString !== "string") { + throw new Error("Expected cross-domain auth to pass a refresh-token-backed session."); + } + capturedRefreshTokens.push(refreshTokenString); + return { + ok: true, + json: async () => ({ redirect_url: "https://example.com/handler/oauth-callback?code=handoff-code&state=handoff-state" }), + }; + }); + + try { + const createCrossDomainAuthRedirectUrl = Reflect.get(clientApp, "_createCrossDomainAuthRedirectUrl"); + if (typeof createCrossDomainAuthRedirectUrl !== "function") { + throw new Error("Expected StackClientApp to expose _createCrossDomainAuthRedirectUrl in tests."); + } + + await expect(createCrossDomainAuthRedirectUrl.call(clientApp, { + redirectUri: "https://example.com/handler/oauth-callback", + state: "handoff-state", + codeChallenge: "abcdefghijklmnopqrstuvwxyzABCDEFG_0123456789-._~", + afterCallbackRedirectUrl: "https://example.com/account-settings", + overrideTokenStoreInit: { + accessToken: "fresh-access-token", + refreshToken: "fresh-refresh-token", + }, + })).resolves.toBe("https://example.com/handler/oauth-callback?code=handoff-code&state=handoff-state"); + } finally { + Reflect.set(clientInterface, "sendClientRequest", originalSendClientRequest); + } + + expect(capturedRefreshTokens).toEqual(["fresh-refresh-token"]); + }); +}); diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 356cf83a3..487f66e8e 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -818,8 +818,11 @@ export class _StackClientAppImplIncomplete { - const session = await this._getSession(undefined, options); + protected async _getCurrentRefreshTokenIdIfSignedIn(options?: { + awaitPendingAuthResolutions?: boolean, + overrideTokenStoreInit?: TokenStoreInit, + }): Promise { + const session = await this._getSession(options?.overrideTokenStoreInit, options); const tokens = await session.getOrFetchLikelyValidTokens(0, null); if (tokens?.refreshToken == null) { return null; @@ -831,6 +834,7 @@ export class _StackClientAppImplIncomplete { const targetUrl = new URL(options.url, options.currentUrl); if (targetUrl.origin === options.currentUrl.origin) { @@ -839,6 +843,7 @@ export class _StackClientAppImplIncomplete {}); } + protected _getTokenStoreInitForFreshTokens(tokens: { accessToken: string | null, refreshToken: string }): TokenStoreInit | undefined { + if (tokens.accessToken == null) { + return undefined; + } + return { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }; + } + protected _hasPersistentTokenStore(overrideTokenStoreInit?: TokenStoreInit): this is StackClientApp { return (overrideTokenStoreInit !== undefined ? overrideTokenStoreInit : this._tokenStoreInit) !== null; } @@ -2760,8 +2777,9 @@ export class _StackClientAppImplIncomplete { - const session = await this._getSession(undefined, { awaitPendingAuthResolutions: options.awaitPendingAuthResolutions }); + const session = await this._getSession(options.overrideTokenStoreInit, { awaitPendingAuthResolutions: options.awaitPendingAuthResolutions }); const response = await this._interface.sendClientRequest( "/auth/oauth/cross-domain/authorize", { @@ -2780,7 +2798,8 @@ export class _StackClientAppImplIncomplete