diff --git a/ui/src/app/impersonate/route.test.ts b/ui/src/app/impersonate/route.test.ts new file mode 100644 index 00000000..472c5e7d --- /dev/null +++ b/ui/src/app/impersonate/route.test.ts @@ -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); + }); +}); diff --git a/ui/src/app/impersonate/route.ts b/ui/src/app/impersonate/route.ts index 53f2823e..9a4acc5f 100644 --- a/ui/src/app/impersonate/route.ts +++ b/ui/src/app/impersonate/route.ts @@ -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=&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-, __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; }