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
45 45
46const AuthCallbackSearch = Schema.Struct({46const AuthCallbackSearch = Schema.Struct({
47 code: Schema.String,47 code: Schema.String,
48 state: Schema.String,
48});49});
49 50
50export const AUTH_PATHS = {51export const AUTH_PATHS = {
apps/cloud/src/auth/handlers.node.test.ts+72−0
@@ -0,0 +1,72 @@
1import {
2 HttpApiBuilder,
3 HttpApp,
4 HttpServer,
5} from "@effect/platform";
6import { describe, expect, it } from "@effect/vitest";
7import { Effect, Layer } from "effect";
8
9import {
10 CloudAuthPublicHandlers,
11 NonProtectedApi,
12} from "./handlers";
13import { UserStoreService } from "./context";
14import { WorkOSAuth } from "./workos";
15
16const 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
46describe("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 @@
1import { HttpApi, HttpApiBuilder, HttpServerResponse } from "@effect/platform";1import { HttpApi, HttpApiBuilder, HttpServerRequest, HttpServerResponse } from "@effect/platform";
2import { Effect } from "effect";2import { Effect } from "effect";
3import { setCookie, deleteCookie } from "@tanstack/react-start/server";3import { setCookie, deleteCookie } from "@tanstack/react-start/server";
4 4
@@ -18,6 +18,40 @@ const COOKIE_OPTIONS = { · 13 unmodified lines
18 secure: true,18 secure: true,
19};19};
20 20
21const STATE_COOKIE = "wos-login-state";
22const STATE_COOKIE_OPTIONS = {
23 path: "/",
24 httpOnly: true,
25 sameSite: "lax" as const,
26 maxAge: 10 * 60,
27 secure: true,
28};
29
30const 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
36const 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
46const 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
21// ---------------------------------------------------------------------------55// ---------------------------------------------------------------------------
22// Single non-protected API surface — public (login/callback) + session56// Single non-protected API surface — public (login/callback) + session
23// (me/logout/organizations/switch-organization). The session group has SessionAuth on it.57// (me/logout/organizations/switch-organization). The session group has SessionAuth on it.
@@ -41,14 +75,25 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 17 unmodified lines
41 // header points at the internal proxy target, not the public URL75 // header points at the internal proxy target, not the public URL
42 // WorkOS needs to redirect back to.76 // WorkOS needs to redirect back to.
43 const origin = env.VITE_PUBLIC_SITE_URL ?? "";77 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);
45 return HttpServerResponse.redirect(url, { status: 302 });81 return HttpServerResponse.redirect(url, { status: 302 });
46 }),82 }),
47 )83 )
48 .handleRaw("callback", ({ urlParams }) =>84 .handleRaw("callback", ({ request, urlParams }) =>
49 Effect.gen(function* () {85 Effect.gen(function* () {
50 const workos = yield* WorkOSAuth;86 const workos = yield* WorkOSAuth;
51 const users = yield* UserStoreService;87 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 }
52 97
53 const result = yield* workos.authenticateWithCode(urlParams.code);98 const result = yield* workos.authenticateWithCode(urlParams.code);
54 99
@@ -79,6 +124,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 24 unmodified lines
79 }124 }
80 125
81 setCookie("wos-session", sealedSession, COOKIE_OPTIONS);126 setCookie("wos-session", sealedSession, COOKIE_OPTIONS);
127 deleteCookie(STATE_COOKIE, { path: "/" });
82 return HttpServerResponse.redirect("/", { status: 302 });128 return HttpServerResponse.redirect("/", { status: 302 });
83 }),129 }),
84 ),130 ),
apps/cloud/src/auth/workos.ts+2−1
@@ -78,11 +78,12 @@ const make = Effect.gen(function* () { · 77 unmodified lines
78 });78 });
79 79
80 return {80 return {
81 getAuthorizationUrl: (redirectUri: string) =>81 getAuthorizationUrl: (redirectUri: string, state?: string) =>
82 workos.userManagement.getAuthorizationUrl({82 workos.userManagement.getAuthorizationUrl({
83 provider: "authkit",83 provider: "authkit",
84 redirectUri,84 redirectUri,
85 clientId,85 clientId,
86 ...(state ? { state } : {}),
86 }),87 }),
87 88
88 authenticateWithCode: (code: string) =>89 authenticateWithCode: (code: string) =>