History

Add state management for OAuth flow and enhance cookie handling

Rhys Sullivan committed 6 months ago

Commit df01d7e1 parent 0e640564 files · +124 −4Browse files

Unified · Split

4 files
  1. apps/cloud/src/auth/api.ts +1 −0
  2. apps/cloud/src/auth/handlers.node.test.ts +72 −0
  3. apps/cloud/src/auth/handlers.ts +49 −3
  4. apps/cloud/src/auth/workos.ts +2 −1
apps/cloud/src/auth/api.ts+1−0
@@ -45,6 +45,7 @@ const CreateOrganizationResponse = Schema.Struct({ · 44 unmodified lines
4545
4646 const AuthCallbackSearch = Schema.Struct({
4747 code: Schema.String,
48+ state: Schema.String,
4849 });
4950
5051 export const AUTH_PATHS = {
apps/cloud/src/auth/handlers.node.test.ts+72−0
@@ -0,0 +1,72 @@
1+import {
2+ HttpApiBuilder,
3+ HttpApp,
4+ HttpServer,
5+} from "@effect/platform";
6+import { describe, expect, it } from "@effect/vitest";
7+import { Effect, Layer } from "effect";
8+
9+import {
10+ CloudAuthPublicHandlers,
11+ NonProtectedApi,
12+} from "./handlers";
13+import { UserStoreService } from "./context";
14+import { WorkOSAuth } from "./workos";
15+
16+const makeAuthFetch = (workos: Partial<WorkOSAuth["Type"]>) => {
17+ const WorkOSTest = Layer.succeed(
18+ WorkOSAuth,
19+ new Proxy(workos as WorkOSAuth["Type"], {
20+ get: (target, prop) => {
21+ if (prop in target) return target[prop as keyof typeof target];
22+ return () => {
23+ throw new Error(`WorkOSAuth.${String(prop)} not stubbed`);
24+ };
25+ },
26+ }),
27+ );
28+ const UserStoreTest = Layer.succeed(UserStoreService, {
29+ use: () => Effect.succeed(undefined),
30+ });
31+ const app = HttpApiBuilder.httpApp.pipe(
32+ Effect.provide(
33+ HttpApiBuilder.api(NonProtectedApi).pipe(
34+ Layer.provide(CloudAuthPublicHandlers),
35+ Layer.provideMerge(WorkOSTest),
36+ Layer.provideMerge(UserStoreTest),
37+ Layer.provideMerge(HttpServer.layerContext),
38+ Layer.provideMerge(HttpApiBuilder.Router.Live),
39+ Layer.provideMerge(HttpApiBuilder.Middleware.layer),
40+ ),
41+ ),
42+ );
43+ return HttpApp.toWebHandler(app);
44+};
45+
46+describe("Auth callback handlers", () => {
47+ it.effect("rejects callback state without the matching login state cookie", () =>
48+ Effect.gen(function* () {
49+ let authenticateCalls = 0;
50+ const fetch = makeAuthFetch({
51+ authenticateWithCode: () =>
52+ Effect.sync(() => {
53+ authenticateCalls++;
54+ return {
55+ user: { id: "user_1" },
56+ organizationId: null,
57+ sealedSession: "sealed_session",
58+ };
59+ }),
60+ });
61+
62+ const response = yield* Effect.promise(() =>
63+ fetch(
64+ new Request("http://test.local/auth/callback?code=attacker-code&state=attacker-state"),
65+ ),
66+ );
67+
68+ expect(response.status).toBe(400);
69+ expect(authenticateCalls).toBe(0);
70+ }),
71+ );
72+});
apps/cloud/src/auth/handlers.ts+49−3
@@ -1,4 +1,4 @@
1−import { HttpApi, HttpApiBuilder, HttpServerResponse } from "@effect/platform";
1+import { HttpApi, HttpApiBuilder, HttpServerRequest, HttpServerResponse } from "@effect/platform";
22 import { Effect } from "effect";
33 import { setCookie, deleteCookie } from "@tanstack/react-start/server";
44
@@ -18,6 +18,40 @@ const COOKIE_OPTIONS = { · 13 unmodified lines
1818 secure: true,
1919 };
2020
21+const STATE_COOKIE = "wos-login-state";
22+const STATE_COOKIE_OPTIONS = {
23+ path: "/",
24+ httpOnly: true,
25+ sameSite: "lax" as const,
26+ maxAge: 10 * 60,
27+ secure: true,
28+};
29+
30+const randomState = (): string => {
31+ const bytes = new Uint8Array(32);
32+ crypto.getRandomValues(bytes);
33+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
34+};
35+
36+const parseCookie = (cookieHeader: string | null, name: string): string | null => {
37+ if (!cookieHeader) return null;
38+ const match = cookieHeader
39+ .split(";")
40+ .map((c) => c.trim())
41+ .find((c) => c.startsWith(`${name}=`));
42+ if (!match) return null;
43+ return match.slice(name.length + 1) || null;
44+};
45+
46+const timingSafeEqual = (a: string, b: string): boolean => {
47+ if (a.length !== b.length) return false;
48+ let diff = 0;
49+ for (let i = 0; i < a.length; i++) {
50+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
51+ }
52+ return diff === 0;
53+};
54+
2155 // ---------------------------------------------------------------------------
2256 // Single non-protected API surface — public (login/callback) + session
2357 // (me/logout/organizations/switch-organization). The session group has SessionAuth on it.
@@ -41,14 +75,25 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 17 unmodified lines
4175 // header points at the internal proxy target, not the public URL
4276 // WorkOS needs to redirect back to.
4377 const origin = env.VITE_PUBLIC_SITE_URL ?? "";
44− const url = workos.getAuthorizationUrl(`${origin}${AUTH_PATHS.callback}`);
78+ const state = randomState();
79+ setCookie(STATE_COOKIE, state, STATE_COOKIE_OPTIONS);
80+ const url = workos.getAuthorizationUrl(`${origin}${AUTH_PATHS.callback}`, state);
4581 return HttpServerResponse.redirect(url, { status: 302 });
4682 }),
4783 )
48− .handleRaw("callback", ({ urlParams }) =>
84+ .handleRaw("callback", ({ request, urlParams }) =>
4985 Effect.gen(function* () {
5086 const workos = yield* WorkOSAuth;
5187 const users = yield* UserStoreService;
88+ const webRequest = yield* Effect.mapError(
89+ HttpServerRequest.toWeb(request),
90+ () => new WorkOSError(),
91+ );
92+ const cookieState = parseCookie(webRequest.headers.get("cookie"), STATE_COOKIE);
93+ if (!cookieState || !timingSafeEqual(cookieState, urlParams.state)) {
94+ deleteCookie(STATE_COOKIE, { path: "/" });
95+ return HttpServerResponse.text("Invalid login state", { status: 400 });
96+ }
5297
5398 const result = yield* workos.authenticateWithCode(urlParams.code);
5499
@@ -79,6 +124,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 24 unmodified lines
79124 }
80125
81126 setCookie("wos-session", sealedSession, COOKIE_OPTIONS);
127+ deleteCookie(STATE_COOKIE, { path: "/" });
82128 return HttpServerResponse.redirect("/", { status: 302 });
83129 }),
84130 ),
apps/cloud/src/auth/workos.ts+2−1
@@ -78,11 +78,12 @@ const make = Effect.gen(function* () { · 77 unmodified lines
7878 });
7979
8080 return {
81− getAuthorizationUrl: (redirectUri: string) =>
81+ getAuthorizationUrl: (redirectUri: string, state?: string) =>
8282 workos.userManagement.getAuthorizationUrl({
8383 provider: "authkit",
8484 redirectUri,
8585 clientId,
86+ ...(state ? { state } : {}),
8687 }),
8788
8889 authenticateWithCode: (code: string) =>