Skip to content

Commit c818ad7

Browse files
committed
Exercise browser binding and replay protection for login state
1 parent 9784801 commit c818ad7

3 files changed

Lines changed: 89 additions & 5 deletions

File tree

apps/cloud/src/auth/handlers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group(
210210
let sealedSession = result.sealedSession;
211211

212212
// Resume where the SSR gate interrupted them. The state passed the
213-
// CSRF check above whenever it's present, but it's still a
213+
// CSRF check above, but it's still a
214214
// round-tripped value, so the returnTo inside it is re-validated like
215215
// any other untrusted path.
216216
const returnTo = safeReturnTo(decodeLoginState(query.state)?.returnTo) ?? "/";

apps/cloud/src/auth/workos-callback-state.node.test.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
// HTTP surface (see api.request-scope.node.test.ts).
1111
// ---------------------------------------------------------------------------
1212

13-
import { describe, expect, it } from "@effect/vitest";
13+
import { afterAll, describe, expect, it } from "@effect/vitest";
1414
import { Effect, Layer } from "effect";
1515
import { HttpRouter, HttpServer } from "effect/unstable/http";
1616
import { HttpApiBuilder } from "effect/unstable/httpapi";
@@ -91,11 +91,13 @@ const App = HttpApiBuilder.layer(PublicApi).pipe(
9191
Layer.provide(HttpServer.layerServices),
9292
);
9393

94+
const app = HttpRouter.toWebHandler(App, { disableLogger: true });
95+
afterAll(() => app.dispose());
96+
9497
const run = (request: Request) => {
95-
const handler = HttpRouter.toWebHandler(App, { disableLogger: true }).handler;
9698
// beta.59: the handler type expects a context argument; this layer stack
9799
// needs none at runtime — pass undefined like the api.request-scope tests.
98-
return handler(request, undefined as never);
100+
return app.handler(request, undefined as never);
99101
};
100102

101103
const callbackUrl = (state?: string, code = "code_1") =>
@@ -109,9 +111,24 @@ describe("workos callback · CSRF state hardening", () => {
109111
expect(res.headers.get("set-cookie") ?? "").not.toContain(SESSION_COOKIE);
110112
});
111113

114+
it("rejects missing state even when the browser has a login cookie", async () => {
115+
const res = await run(
116+
new Request(callbackUrl(undefined), {
117+
headers: { cookie: `${STATE_COOKIE}=victim-login-state` },
118+
redirect: "manual",
119+
}),
120+
);
121+
expect(res.status).toBe(400);
122+
expect(await res.text()).toBe("Invalid login state");
123+
expect(res.headers.get("set-cookie") ?? "").not.toContain(SESSION_COOKIE);
124+
});
125+
112126
it("rejects a state that does not match the login cookie", async () => {
113127
const res = await run(
114-
new Request(callbackUrl("attacker-controlled-state"), { redirect: "manual" }),
128+
new Request(callbackUrl("attacker-controlled-state"), {
129+
headers: { cookie: `${STATE_COOKIE}=victim-login-state` },
130+
redirect: "manual",
131+
}),
115132
);
116133
expect(res.status).toBe(400);
117134
expect(await res.text()).toContain("Invalid login state");

e2e/cloud/login-csrf.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { randomUUID } from "node:crypto";
2+
3+
import { expect } from "@effect/vitest";
4+
import { Effect } from "effect";
5+
6+
import { scenario } from "../src/scenario";
7+
import { Browser, Target } from "../src/services";
8+
9+
scenario(
10+
"Login CSRF · state is required, bound to the browser, and consumed after login",
11+
{ timeout: 180_000 },
12+
Effect.gen(function* () {
13+
const target = yield* Target;
14+
const browser = yield* Browser;
15+
const email = `csrf-${randomUUID()}@e2e.test`;
16+
yield* browser.session({ label: "anonymous" }, async ({ page, step }) => {
17+
const interceptCallback = async (): Promise<string> => {
18+
let callback: string | undefined;
19+
await page.route("**/api/auth/callback?**", async (route) => {
20+
callback = route.request().url();
21+
await route.abort();
22+
});
23+
await page.goto(new URL("/api/auth/login", target.baseUrl).toString());
24+
await page.getByPlaceholder("new-user@example.com").fill(email);
25+
await page.getByRole("button", { name: /Continue/ }).click();
26+
await expect.poll(() => callback).toBeDefined();
27+
await page.unroute("**/api/auth/callback?**");
28+
if (!callback) throw new Error("AuthKit did not return a callback");
29+
return callback;
30+
};
31+
await step("Refuse a valid authorization code with no state", async () => {
32+
const callback = new URL(await interceptCallback());
33+
callback.searchParams.delete("state");
34+
const response = await page.request.get(callback.toString(), { maxRedirects: 0 });
35+
expect(response.status()).toBe(400);
36+
expect(await response.text()).toBe("Invalid login state");
37+
expect(
38+
(await page.context().cookies()).some((cookie) => cookie.name === "wos-session"),
39+
).toBe(false);
40+
});
41+
await step("Refuse a state from another login", async () => {
42+
const callback = new URL(await interceptCallback());
43+
callback.searchParams.set("state", "another-browser-state");
44+
const response = await page.request.get(callback.toString(), { maxRedirects: 0 });
45+
expect(response.status()).toBe(400);
46+
expect(await response.text()).toBe("Invalid login state");
47+
expect(
48+
(await page.context().cookies()).some((cookie) => cookie.name === "wos-session"),
49+
).toBe(false);
50+
});
51+
await step("Complete a fresh login, then reject the same callback again", async () => {
52+
const callback = await interceptCallback();
53+
await page.goto(callback);
54+
await page.waitForURL((url) => url.pathname === "/create-org", { timeout: 30_000 });
55+
const cookies = await page.context().cookies();
56+
expect(cookies.some((cookie) => cookie.name === "wos-session")).toBe(true);
57+
expect(cookies.some((cookie) => cookie.name === "wos-login-state")).toBe(false);
58+
const me = await page.request.get(new URL("/api/auth/me", target.baseUrl).toString());
59+
expect(me.status()).toBe(200);
60+
expect(await me.json()).toMatchObject({ user: { email } });
61+
const replay = await page.request.get(callback, { maxRedirects: 0 });
62+
expect(replay.status()).toBe(400);
63+
expect(await replay.text()).toBe("Invalid login state");
64+
});
65+
});
66+
}),
67+
);

0 commit comments

Comments
 (0)