mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
fix(sdk): stop hosted-component redirect loop from anonymous session handoff
Co-Authored-By: Konstantin Wohlwend <[email protected]>
This commit is contained in:
co-authored by
Konstantin Wohlwend
parent
90fc99b7bf
commit
6c3dcdcc87
+74
-2
@@ -4,7 +4,7 @@ import { Store } from "@hexclave/shared/dist/utils/stores";
|
||||
import { hexclaveAppInternalsSymbol } from "../../common";
|
||||
import { StackClientApp } from "../interfaces/client-app";
|
||||
|
||||
function createAccessTokenString(refreshTokenId: string): string {
|
||||
function createAccessTokenString(refreshTokenId: string, options?: { isAnonymous?: boolean }): string {
|
||||
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
return [
|
||||
@@ -24,7 +24,7 @@ function createAccessTokenString(refreshTokenId: string): string {
|
||||
email_verified: false,
|
||||
selected_team_id: null,
|
||||
signed_up_at: nowSeconds,
|
||||
is_anonymous: false,
|
||||
is_anonymous: options?.isAnonymous ?? false,
|
||||
is_restricted: false,
|
||||
restricted_reason: null,
|
||||
requires_totp_mfa: false,
|
||||
@@ -166,6 +166,78 @@ describe("StackClientApp cross-domain auth", () => {
|
||||
expect(capturedAccessTokenRefreshTokenIds).toEqual(["fresh-refresh-token-id"]);
|
||||
});
|
||||
|
||||
it("does not hand off an anonymous session as the current nested cross-domain auth session", async () => {
|
||||
// An analytics-created anonymous session must not be treated as "signed in" for a session
|
||||
// handoff. Otherwise, redirecting to hosted sign-in attaches the anonymous refresh token, the
|
||||
// hosted domain adopts it, decides it is already signed in, and immediately mints a code back
|
||||
// to the app — which lands on sign-in again and re-triggers the handoff, an infinite loop.
|
||||
const clientApp = new StackClientApp({
|
||||
baseUrl: "http://localhost:12345",
|
||||
projectId: "00000000-0000-4000-8000-00000000000a",
|
||||
publishableClientKey: "stack-pk-test",
|
||||
tokenStore: {
|
||||
accessToken: createAccessTokenString("anonymous-refresh-token-id", { isAnonymous: true }),
|
||||
refreshToken: "anonymous-refresh-token",
|
||||
},
|
||||
redirectMethod: "none",
|
||||
noAutomaticPrefetch: true,
|
||||
});
|
||||
|
||||
const clientInterface = Reflect.get(clientApp, "_interface");
|
||||
const originalFetchNewAccessToken = Reflect.get(clientInterface, "fetchNewAccessToken");
|
||||
Reflect.set(clientInterface, "fetchNewAccessToken", async () => {
|
||||
return AccessToken.createIfValid(createAccessTokenString("anonymous-refresh-token-id", { isAnonymous: true })) ?? (() => {
|
||||
throw new Error("Expected test access token to be valid");
|
||||
})();
|
||||
});
|
||||
|
||||
try {
|
||||
const fetchCurrentRefreshTokenIdIfSignedIn = Reflect.get(clientApp, "_fetchCurrentRefreshTokenIdIfSignedIn");
|
||||
if (typeof fetchCurrentRefreshTokenIdIfSignedIn !== "function") {
|
||||
throw new Error("Expected StackClientApp to expose _fetchCurrentRefreshTokenIdIfSignedIn in tests.");
|
||||
}
|
||||
await expect(fetchCurrentRefreshTokenIdIfSignedIn.call(clientApp, {
|
||||
awaitPendingAuthResolutions: false,
|
||||
})).resolves.toBe(null);
|
||||
} finally {
|
||||
Reflect.set(clientInterface, "fetchNewAccessToken", originalFetchNewAccessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it("still hands off a real (non-anonymous) session as the current nested cross-domain auth session", async () => {
|
||||
const clientApp = new StackClientApp({
|
||||
baseUrl: "http://localhost:12345",
|
||||
projectId: "00000000-0000-4000-8000-00000000000b",
|
||||
publishableClientKey: "stack-pk-test",
|
||||
tokenStore: {
|
||||
accessToken: createAccessTokenString("real-refresh-token-id"),
|
||||
refreshToken: "real-refresh-token",
|
||||
},
|
||||
redirectMethod: "none",
|
||||
noAutomaticPrefetch: true,
|
||||
});
|
||||
|
||||
const clientInterface = Reflect.get(clientApp, "_interface");
|
||||
const originalFetchNewAccessToken = Reflect.get(clientInterface, "fetchNewAccessToken");
|
||||
Reflect.set(clientInterface, "fetchNewAccessToken", async () => {
|
||||
return AccessToken.createIfValid(createAccessTokenString("real-refresh-token-id")) ?? (() => {
|
||||
throw new Error("Expected test access token to be valid");
|
||||
})();
|
||||
});
|
||||
|
||||
try {
|
||||
const fetchCurrentRefreshTokenIdIfSignedIn = Reflect.get(clientApp, "_fetchCurrentRefreshTokenIdIfSignedIn");
|
||||
if (typeof fetchCurrentRefreshTokenIdIfSignedIn !== "function") {
|
||||
throw new Error("Expected StackClientApp to expose _fetchCurrentRefreshTokenIdIfSignedIn in tests.");
|
||||
}
|
||||
await expect(fetchCurrentRefreshTokenIdIfSignedIn.call(clientApp, {
|
||||
awaitPendingAuthResolutions: false,
|
||||
})).resolves.toBe("real-refresh-token-id");
|
||||
} finally {
|
||||
Reflect.set(clientInterface, "fetchNewAccessToken", originalFetchNewAccessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a fresh nested OAuth state while preserving the outer cross-domain return state", async () => {
|
||||
const projectId = "00000000-0000-4000-8000-000000000002";
|
||||
const clientApp = new StackClientApp({
|
||||
|
||||
@@ -942,6 +942,15 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
||||
if (tokens?.refreshToken == null) {
|
||||
return null;
|
||||
}
|
||||
// Anonymous sessions (e.g. the one analytics silently creates on page load) must not be handed
|
||||
// off across domains. If we did, redirecting to a hosted sign-in page would attach this
|
||||
// session's refresh token, the hosted domain would adopt the anonymous session and treat itself
|
||||
// as "signed in", and then immediately mint a callback code back to the app — which lands on the
|
||||
// sign-in page again and re-triggers the handoff, producing an infinite cross-domain redirect
|
||||
// loop instead of ever showing the sign-in form.
|
||||
if (tokens.accessToken.payload.is_anonymous) {
|
||||
return null;
|
||||
}
|
||||
return tokens.accessToken.payload.refresh_token_id;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user