mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-22 11:51:04 +02:00
fix: clear both cookie jars during superadmin impersonation (#558)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f69ab294af
commit
3ab3ded674
2 changed files with 370 additions and 70 deletions
227
ui/src/app/impersonate/route.test.ts
Normal file
227
ui/src/app/impersonate/route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue