fix: clear both cookie jars during superadmin impersonation (#558)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Abhishek 2026-07-18 14:53:15 +05:30 committed by GitHub
parent f69ab294af
commit 3ab3ded674
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 370 additions and 70 deletions

View file

@ -0,0 +1,227 @@
// @vitest-environment node
import { NextRequest } from "next/server";
import { describe, expect, it, vi } from "vitest";
import { GET } from "./route";
const PROJECT_ID = "proj-123";
vi.mock("@/lib/auth/config", () => ({
getStackConfig: vi.fn(async () => ({
projectId: "proj-123",
publishableClientKey: "pck_test",
})),
}));
function makeRequest(
url: string,
{ cookie, forwardedProto }: { cookie?: string; forwardedProto?: string } = {},
) {
const headers = new Headers();
if (cookie) headers.set("cookie", cookie);
if (forwardedProto) headers.set("x-forwarded-proto", forwardedProto);
return new NextRequest(url, { headers });
}
function parseSetCookie(header: string) {
const [nameValue, ...attrParts] = header.split("; ");
const eq = nameValue.indexOf("=");
const attrs = attrParts.map((a) => a.toLowerCase());
return {
name: nameValue.slice(0, eq),
value: decodeURIComponent(nameValue.slice(eq + 1)),
partitioned: attrs.includes("partitioned"),
secure: attrs.includes("secure"),
maxAge: Number(
attrParts.find((a) => a.toLowerCase().startsWith("max-age="))?.slice(8),
),
domain: attrParts
.find((a) => a.toLowerCase().startsWith("domain="))
?.slice(7),
};
}
describe("GET /impersonate", () => {
it("returns 400 when refresh_token is missing", async () => {
const response = await GET(
makeRequest("https://app.dograh.com/impersonate"),
);
expect(response.status).toBe(400);
});
it("redirects to redirect_path on the same origin", async () => {
const response = await GET(
makeRequest(
"https://app.dograh.com/impersonate?refresh_token=rt-new&redirect_path=/workflow/42",
),
);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"https://app.dograh.com/workflow/42",
);
});
it("falls back to /workflow/create for a cross-origin redirect_path", async () => {
const response = await GET(
makeRequest(
"https://app.dograh.com/impersonate?refresh_token=rt-new&redirect_path=https://evil.com/phish",
),
);
expect(response.headers.get("location")).toBe(
"https://app.dograh.com/workflow/create",
);
});
it("falls back to /workflow/create for a malformed redirect_path", async () => {
const response = await GET(
makeRequest(
"https://app.dograh.com/impersonate?refresh_token=rt-new&redirect_path=https%3A%2F%2F",
),
);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"https://app.dograh.com/workflow/create",
);
});
it("clears presented session cookies in both jars and all domain scopes on https", async () => {
const hostRefresh = `__Host-hexclave-refresh-${PROJECT_ID}--default`;
const response = await GET(
makeRequest(
"https://app.dograh.com/impersonate?refresh_token=rt-new",
{
cookie: [
`${hostRefresh}=old-session`,
"hexclave-access=old-access",
`stack-refresh-${PROJECT_ID}=old-legacy`,
`hexclave-refresh-${PROJECT_ID}--custom-abc=old-custom`,
"stack-oauth-inner-xyz=oauth-state",
"hexclave-is-https=true",
"theme=dark",
].join("; "),
},
),
);
const cookies = response.headers.getSetCookie().map(parseSetCookie);
// Deletions exist for both jars for a regular-named cookie.
const accessDeletions = cookies.filter(
(c) => c.name === "hexclave-access" && c.maxAge === 0,
);
expect(accessDeletions.some((c) => c.partitioned)).toBe(true);
expect(accessDeletions.some((c) => !c.partitioned)).toBe(true);
// ...and for the host-only plus each parent-domain scope.
const accessDomains = new Set(accessDeletions.map((c) => c.domain));
expect(accessDomains).toEqual(
new Set([undefined, "app.dograh.com", "dograh.com"]),
);
// Enumerated --custom-* cookies are cleared too.
expect(
cookies.some(
(c) =>
c.name === `hexclave-refresh-${PROJECT_ID}--custom-abc` &&
c.maxAge === 0 &&
c.partitioned,
),
).toBe(true);
// __Host- deletions never carry a Domain attribute but still cover both jars.
const hostDeletions = cookies.filter(
(c) => c.name === hostRefresh && c.maxAge === 0,
);
expect(hostDeletions.length).toBeGreaterThan(0);
expect(hostDeletions.every((c) => c.domain === undefined)).toBe(true);
expect(hostDeletions.some((c) => c.partitioned)).toBe(true);
// The legacy raw refresh cookie is only ever deleted, never re-set.
expect(
cookies
.filter((c) => c.name === `stack-refresh-${PROJECT_ID}`)
.every((c) => c.maxAge === 0),
).toBe(true);
// Non-identity cookies are left alone: app cookies, in-flight OAuth
// state, and the SDK's is-https flag.
expect(cookies.some((c) => c.name === "theme")).toBe(false);
expect(cookies.some((c) => c.name === "stack-oauth-inner-xyz")).toBe(
false,
);
expect(cookies.some((c) => c.name === "hexclave-is-https")).toBe(false);
// Deletions cover only cookies the browser presented (plus the fresh
// set) — no blanket static list bloating the header block.
expect(cookies.some((c) => c.name === "stack-access")).toBe(false);
});
it("sets the fresh refresh cookie once with Partitioned, after the deletions", async () => {
const hostRefresh = `__Host-hexclave-refresh-${PROJECT_ID}--default`;
const response = await GET(
makeRequest(
"https://app.dograh.com/impersonate?refresh_token=rt-new",
{ cookie: `${hostRefresh}=old-session` },
),
);
const cookies = response.headers.getSetCookie().map(parseSetCookie);
// Exactly one fresh set: CHIPS browsers store it partitioned (where
// the SDK writes), non-CHIPS browsers ignore the attribute and store
// it in the regular jar (also where the SDK writes). A second copy in
// the other jar would become a stale shadow the SDK never updates.
const freshSets = cookies.filter(
(c) => c.name === hostRefresh && c.maxAge > 0,
);
expect(freshSets).toHaveLength(1);
expect(freshSets[0].partitioned).toBe(true);
expect(freshSets[0].secure).toBe(true);
expect(JSON.parse(freshSets[0].value).refresh_token).toBe("rt-new");
// The fresh set must come after every deletion of the same name,
// otherwise the browser would apply a deletion last.
const lastDeletionIdx = cookies.reduce(
(acc, c, i) =>
c.name === hostRefresh && c.maxAge === 0 ? i : acc,
-1,
);
const firstFreshIdx = cookies.findIndex(
(c) => c.name === hostRefresh && c.maxAge > 0,
);
expect(firstFreshIdx).toBeGreaterThan(lastDeletionIdx);
});
it("honors x-forwarded-proto case-insensitively when the request is http", async () => {
const response = await GET(
makeRequest("http://app.dograh.com/impersonate?refresh_token=rt", {
forwardedProto: "HTTPS",
}),
);
const cookies = response.headers.getSetCookie().map(parseSetCookie);
const freshSets = cookies.filter(
(c) =>
c.name === `__Host-hexclave-refresh-${PROJECT_ID}--default` &&
c.maxAge > 0,
);
expect(freshSets).toHaveLength(1);
expect(freshSets[0].partitioned).toBe(true);
});
it("uses no __Host- prefix and no partitioned attribute on plain http", async () => {
const response = await GET(
makeRequest("http://localhost:3010/impersonate?refresh_token=rt", {
cookie: `hexclave-refresh-${PROJECT_ID}--default=old`,
}),
);
const cookies = response.headers.getSetCookie().map(parseSetCookie);
expect(cookies.some((c) => c.partitioned)).toBe(false);
expect(cookies.some((c) => c.domain !== undefined)).toBe(false);
const freshSets = cookies.filter(
(c) =>
c.name === `hexclave-refresh-${PROJECT_ID}--default` &&
c.maxAge > 0,
);
expect(freshSets).toHaveLength(1);
expect(freshSets[0].secure).toBe(false);
});
});

View file

@ -3,13 +3,91 @@ import { NextRequest, NextResponse } from "next/server";
import { getStackConfig } from "@/lib/auth/config";
/**
* Helper route that receives a Stack refresh token via query parameters, stores
* it as the regular Stack SDK cookie *for the current sub-domain only* and finally
* redirects the user to the requested path.
* Helper route that receives a Stack refresh token via query parameters, wipes
* every Stack SDK session cookie the browser presented, stores the impersonated
* session as a fresh cookie and finally redirects to the requested path.
*
* On HTTPS Chrome the Stack SDK writes its cookies into the CHIPS-partitioned
* jar (`Secure; SameSite=None; Partitioned`), which coexists with the regular
* jar under the same cookie name. A Set-Cookie without the `Partitioned`
* attribute can neither delete nor overwrite the partitioned copy, and the
* SDK's own cookie parsing is first-occurrence-wins so a leftover partitioned
* cookie from a previous session silently keeps winning over anything this
* route sets in the regular jar, resurfacing the old user. Deletions below are
* therefore emitted for BOTH jars (and for possible parent-domain scopes),
* which is also why headers are appended manually: `response.cookies.set`
* dedupes Set-Cookie by name.
*
* The fresh cookie is deliberately written only ONCE, with the `Partitioned`
* attribute: CHIPS browsers store it in the partitioned jar and non-CHIPS
* browsers ignore the attribute and store it in the regular jar in both
* cases exactly the jar the SDK's own writes will later overwrite. Writing
* both jars instead would plant a copy the SDK never updates, recreating the
* stale-session bug this route exists to fix.
*
* Example usage (client side):
* /impersonate?refresh_token=<REFRESH>&redirect_path=/workflow/123
*/
// Stack SDK cookies that hold session identity: hexclave/stack access cookies
// and every refresh-cookie variant (bare legacy, project-scoped legacy,
// --default, --custom-<domain>, __Host- prefixed). Deliberately excludes
// non-identity SDK cookies (is-https flags, in-flight OAuth state) so an
// impersonation redirect can't abort an unrelated concurrent sign-in.
const SESSION_COOKIE_RE = /^(?:__Host-)?(?:stack|hexclave)-(?:access|refresh)(?:-|$)/;
/**
* Domains a cookie could have been scoped to from this host, e.g.
* "app.dograh.com" -> ["app.dograh.com", "dograh.com"]. Returns [] for
* localhost / IP hosts. Stops before the last label, which over-generates for
* multi-label public suffixes (app.example.co.uk also yields co.uk) the
* browser just rejects those deletions, so the cost is a wasted header.
*/
function parentDomains(hostname: string): string[] {
if (!hostname.includes(".") || /^[\d.]+$/.test(hostname)) {
return [];
}
const parts = hostname.split(".");
const domains: string[] = [];
for (let i = 0; i + 2 <= parts.length; i++) {
domains.push(parts.slice(i).join("."));
}
return domains;
}
interface SetCookieAttrs {
maxAge: number;
secure?: boolean;
domain?: string;
partitioned?: boolean;
}
// No HttpOnly: the Stack SDK reads these cookies from document.cookie.
function serializeSetCookie(
name: string,
value: string,
attrs: SetCookieAttrs,
): string {
const parts = [
`${name}=${encodeURIComponent(value)}`,
"Path=/",
`Max-Age=${attrs.maxAge}`,
];
if (attrs.domain) {
parts.push(`Domain=${attrs.domain}`);
}
if (attrs.partitioned) {
// CHIPS requires Secure and SameSite=None.
parts.push("Secure", "SameSite=None", "Partitioned");
} else {
if (attrs.secure) {
parts.push("Secure");
}
parts.push("SameSite=Lax");
}
return parts.join("; ");
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
@ -27,85 +105,80 @@ export async function GET(request: NextRequest) {
return new Response("Stack auth is not configured", { status: 400 });
}
const requestedRedirectUrl = new URL(redirectPath, request.url);
const fallbackRedirectUrl = new URL("/workflow/create", request.url);
const redirectUrl =
requestedRedirectUrl.origin === request.nextUrl.origin
? requestedRedirectUrl.toString()
: fallbackRedirectUrl.toString();
let redirectUrl = fallbackRedirectUrl.toString();
try {
const requestedRedirectUrl = new URL(redirectPath, request.url);
if (requestedRedirectUrl.origin === request.nextUrl.origin) {
redirectUrl = requestedRedirectUrl.toString();
}
} catch {
// Malformed redirect_path (e.g. "https://") — keep the fallback.
}
const response = NextResponse.redirect(redirectUrl);
const forwardedProto = request.headers
.get("x-forwarded-proto")
?.split(",")[0]
?.trim()
.toLowerCase();
const isSecure =
request.nextUrl.protocol === "https:" ||
request.headers.get("x-forwarded-proto") === "https";
const refreshCookieName = `${isSecure ? "__Host-" : ""}hexclave-refresh-${stackConfig.projectId}--default`;
const accessCookieName = "hexclave-access";
const refreshMaxAge = 60 * 60 * 24 * 365;
request.nextUrl.protocol === "https:" || forwardedProto === "https";
// Store the refresh token using the cookie name/shape Stack's current
// nextjs-cookie token store reads. The old route only set the legacy
// refresh cookie, which let a stale access cookie keep the browser on the
// previous app-domain session.
response.cookies.set(
refreshCookieName,
JSON.stringify({
refresh_token: refreshToken,
updated_at_millis: Date.now(),
}),
{
path: "/",
maxAge: refreshMaxAge,
secure: isSecure,
httpOnly: false, // Must be accessible from the browser for Stack SDK
sameSite: "lax",
},
);
const staleCookieNames = new Set([
accessCookieName,
`stack-refresh-${stackConfig.projectId}`,
"stack-refresh",
`stack-refresh-${stackConfig.projectId}--default`,
`__Host-stack-refresh-${stackConfig.projectId}--default`,
`hexclave-refresh-${stackConfig.projectId}--default`,
`__Host-hexclave-refresh-${stackConfig.projectId}--default`,
"stack-access",
]);
staleCookieNames.delete(refreshCookieName);
// Every scope a stale SDK cookie may live in: host-only plus each parent
// domain, each in the regular jar and (on https) its partitioned twin. The
// request's Cookie header is the complete list of names to clear: the SDK
// only sets Lax or None+Partitioned cookies, both of which the browser
// attaches to this top-level navigation.
const domains: (string | undefined)[] = [
undefined,
...parentDomains(request.nextUrl.hostname),
];
const jars = isSecure ? [false, true] : [false];
const setCookieHeaders: string[] = [];
for (const cookie of request.cookies.getAll()) {
const name = cookie.name;
if (name !== refreshCookieName && (
name.startsWith(`hexclave-refresh-${stackConfig.projectId}--custom-`) ||
name.startsWith(`stack-refresh-${stackConfig.projectId}--custom-`) ||
name.startsWith(`__Host-hexclave-refresh-${stackConfig.projectId}--`) ||
name.startsWith(`__Host-stack-refresh-${stackConfig.projectId}--`)
)) {
staleCookieNames.add(name);
if (!SESSION_COOKIE_RE.test(cookie.name)) {
continue;
}
const isHostPrefixed = cookie.name.startsWith("__Host-");
for (const partitioned of jars) {
for (const domain of domains) {
if (isHostPrefixed && domain) {
continue; // __Host- cookies never have a Domain attribute
}
setCookieHeaders.push(
serializeSetCookie(cookie.name, "", {
maxAge: 0,
secure: isHostPrefixed || isSecure,
domain,
partitioned,
}),
);
}
}
}
for (const name of staleCookieNames) {
response.cookies.set(name, "", {
path: "/",
maxAge: 0,
secure: name.startsWith("__Host-") || isSecure,
httpOnly: false,
sameSite: "lax",
});
}
// Keep writing the legacy project refresh cookie for compatibility with
// older Stack SDK builds, but the structured Hexclave cookies above are the
// source of truth for the current app.
response.cookies.set(`stack-refresh-${stackConfig.projectId}`, refreshToken, {
path: "/",
maxAge: refreshMaxAge,
secure: isSecure,
httpOnly: false, // Must be accessible from the browser for Stack SDK
sameSite: "lax",
// Fresh impersonated session, written AFTER the deletions so it survives
// them, in the name/shape Stack's nextjs-cookie token store reads. Single
// write with Partitioned (see the header comment for why).
const refreshCookieName = `${isSecure ? "__Host-" : ""}hexclave-refresh-${stackConfig.projectId}--default`;
const refreshCookieValue = JSON.stringify({
refresh_token: refreshToken,
updated_at_millis: Date.now(),
});
setCookieHeaders.push(
serializeSetCookie(refreshCookieName, refreshCookieValue, {
maxAge: 60 * 60 * 24 * 365,
secure: isSecure,
partitioned: isSecure,
}),
);
for (const header of setCookieHeaders) {
response.headers.append("set-cookie", header);
}
return response;
}