| @@ -1,4 +1,4 @@ |
| 1 | import { HttpApi, HttpApiBuilder, HttpServerResponse } from "@effect/platform"; | 1 | import { HttpApi, HttpApiBuilder, HttpServerRequest, HttpServerResponse } from "@effect/platform"; |
| 2 | import { Effect } from "effect"; | 2 | import { Effect } from "effect"; |
| 3 | import { setCookie, deleteCookie } from "@tanstack/react-start/server"; | 3 | import { 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 | |
| | 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 | // Single non-protected API surface — public (login/callback) + session | 56 | // 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 URL | 75 | // 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 | ), |