| @@ -1,4 +1,4 @@ |
| 1 | | −import { HttpApi, HttpApiBuilder, HttpServerResponse } from "@effect/platform"; |
| 1 | +import { HttpApi, HttpApiBuilder, HttpServerRequest, HttpServerResponse } from "@effect/platform"; |
| 2 | 2 | import { Effect } from "effect"; |
| 3 | 3 | import { setCookie, deleteCookie } from "@tanstack/react-start/server"; |
| 4 | 4 | |
| @@ -18,6 +18,40 @@ const COOKIE_OPTIONS = { · 13 unmodified lines |
| 18 | 18 | secure: true, |
| 19 | 19 | }; |
| 20 | 20 | |
| 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 | + |
| 21 | 55 | // --------------------------------------------------------------------------- |
| 22 | 56 | // Single non-protected API surface — public (login/callback) + session |
| 23 | 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 | 75 | // header points at the internal proxy target, not the public URL |
| 42 | 76 | // WorkOS needs to redirect back to. |
| 43 | 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 | 81 | return HttpServerResponse.redirect(url, { status: 302 }); |
| 46 | 82 | }), |
| 47 | 83 | ) |
| 48 | | − .handleRaw("callback", ({ urlParams }) => |
| 84 | + .handleRaw("callback", ({ request, urlParams }) => |
| 49 | 85 | Effect.gen(function* () { |
| 50 | 86 | const workos = yield* WorkOSAuth; |
| 51 | 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 | 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 | 126 | setCookie("wos-session", sealedSession, COOKIE_OPTIONS); |
| 127 | + deleteCookie(STATE_COOKIE, { path: "/" }); |
| 82 | 128 | return HttpServerResponse.redirect("/", { status: 302 }); |
| 83 | 129 | }), |
| 84 | 130 | ), |