import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpServerResponse } from "effect/unstable/http"; import { Duration, Effect } from "effect"; import { setCookie, deleteCookie } from "@tanstack/react-start/server"; import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api"; import { SessionContext } from "./middleware"; import { UserStoreService } from "./context"; import { authorizeOrganization } from "./authorize-organization"; import { env } from "cloudflare:workers"; import { WorkOSError } from "./errors"; import { WorkOSAuth } from "./workos"; const COOKIE_OPTIONS = { path: "/", httpOnly: true, sameSite: "lax" as const, maxAge: 60 * 60 * 24 * 7, secure: true, }; const STATE_COOKIE = "wos-login-state"; const STATE_COOKIE_OPTIONS = { path: "/", httpOnly: true, sameSite: "lax" as const, maxAge: 10 * 60, secure: true, }; const RESPONSE_COOKIE_OPTIONS = { ...COOKIE_OPTIONS, maxAge: Duration.days(7), }; const RESPONSE_STATE_COOKIE_OPTIONS = { ...STATE_COOKIE_OPTIONS, maxAge: Duration.minutes(10), }; const DELETE_COOKIE_OPTIONS = { path: "/", httpOnly: true, sameSite: "lax" as const, maxAge: 0, expires: new Date(0), secure: true, }; const randomState = (): string => { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); }; const timingSafeEqual = (a: string, b: string): boolean => { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) { diff |= a.charCodeAt(i) ^ b.charCodeAt(i); } return diff === 0; }; const setResponseCookie = ( response: HttpServerResponse.HttpServerResponse, name: string, value: string, options: typeof RESPONSE_COOKIE_OPTIONS, ) => HttpServerResponse.setCookieUnsafe(response, name, value, options); const deleteResponseCookie = ( response: HttpServerResponse.HttpServerResponse, name: string, ) => HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS); // --------------------------------------------------------------------------- // Single non-protected API surface — public (login/callback) + session // (me/logout/organizations/switch-organization). The session group has SessionAuth on it. // --------------------------------------------------------------------------- export const NonProtectedApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi).add(CloudAuthApi); // --------------------------------------------------------------------------- // Public auth handlers (no authentication required) // --------------------------------------------------------------------------- export const CloudAuthPublicHandlers = HttpApiBuilder.group( NonProtectedApi, "cloudAuthPublic", (handlers) => handlers .handleRaw("login", () => Effect.gen(function* () { const workos = yield* WorkOSAuth; // Use the explicit public site URL — in dev, the request's Host // header points at the internal proxy target, not the public URL // WorkOS needs to redirect back to. const origin = env.VITE_PUBLIC_SITE_URL ?? ""; const state = randomState(); const url = workos.getAuthorizationUrl(`${origin}${AUTH_PATHS.callback}`, state); return setResponseCookie( HttpServerResponse.redirect(url, { status: 302 }), STATE_COOKIE, state, RESPONSE_STATE_COOKIE_OPTIONS, ); }), ) .handleRaw("callback", ({ request, query }) => Effect.gen(function* () { const workos = yield* WorkOSAuth; const users = yield* UserStoreService; const cookieState = request.cookies[STATE_COOKIE] ?? null; if (!cookieState || !timingSafeEqual(cookieState, query.state)) { return deleteResponseCookie( HttpServerResponse.text("Invalid login state", { status: 400 }), STATE_COOKIE, ); } const result = yield* workos.authenticateWithCode(query.code); // Mirror the account locally yield* users.use((s) => s.ensureAccount(result.user.id)); let sealedSession = result.sealedSession; // If the auth response didn't surface an org but the user already // belongs to one, rehydrate the session with it. If they have no // memberships at all, leave the session org-less — the frontend // AuthGate will render the onboarding flow. We never auto-create // organizations on login. if (!result.organizationId && sealedSession) { const memberships = yield* workos.listUserMemberships(result.user.id); const existing = memberships.data[0]; if (existing) { const refreshed = yield* workos.refreshSession( sealedSession, existing.organizationId, ); if (refreshed) sealedSession = refreshed; } } if (!sealedSession) { return HttpServerResponse.text("Failed to create session", { status: 500 }); } return deleteResponseCookie( setResponseCookie( HttpServerResponse.redirect("/", { status: 302 }), "wos-session", sealedSession, RESPONSE_COOKIE_OPTIONS, ), STATE_COOKIE, ); }), ), ); // --------------------------------------------------------------------------- // Session auth handlers (require session, may or may not have an org) // --------------------------------------------------------------------------- export const CloudSessionAuthHandlers = HttpApiBuilder.group( NonProtectedApi, "cloudAuth", (handlers) => handlers .handle("me", () => Effect.gen(function* () { const session = yield* SessionContext; const org = session.organizationId ? yield* authorizeOrganization(session.accountId, session.organizationId) : null; return { user: { id: session.accountId, email: session.email, name: session.name, avatarUrl: session.avatarUrl, }, organization: org ? { id: org.id, name: org.name } : null, }; }), ) .handleRaw("logout", () => { deleteCookie("wos-session", { path: "/" }); return Effect.succeed(HttpServerResponse.redirect("/", { status: 302 })); }) .handle("organizations", () => Effect.gen(function* () { const workos = yield* WorkOSAuth; const session = yield* SessionContext; const memberships = yield* workos.listUserMemberships(session.accountId); const organizations = yield* Effect.all( memberships.data.map((m) => workos.getOrganization(m.organizationId).pipe( Effect.map((org) => ({ id: org.id, name: org.name })), Effect.orElseSucceed(() => null), ), ), { concurrency: "unbounded" }, ); return { organizations: organizations.filter((org): org is NonNullable => org !== null), activeOrganizationId: session.organizationId, }; }), ) .handle("switchOrganization", ({ payload }) => Effect.gen(function* () { const workos = yield* WorkOSAuth; const session = yield* SessionContext; const refreshed = yield* workos.refreshSession( session.sealedSession, payload.organizationId, ); if (refreshed) { setCookie("wos-session", refreshed, COOKIE_OPTIONS); } }), ) .handle("createOrganization", ({ payload }) => Effect.gen(function* () { const workos = yield* WorkOSAuth; const users = yield* UserStoreService; const session = yield* SessionContext; const name = payload.name.trim(); const org = yield* workos.createOrganization(name); yield* workos.createMembership(org.id, session.accountId, "admin"); yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name })); // Try to attach the new org to the current session. This can fail // (or silently return a session still scoped to the old org) when // the caller's current session is stale — most commonly after the // user was removed from the org their cookie is pinned to. In that // case we can't repair the session in-place, so we clear the // cookie and fail loudly; the frontend will bounce to login and // the callback's rehydrate path will pick up the new membership. const refreshed = yield* workos.refreshSession(session.sealedSession, org.id); const verified = refreshed ? yield* workos.authenticateSealedSession(refreshed) : null; if (!refreshed || !verified || verified.organizationId !== org.id) { yield* Effect.logWarning( "createOrganization: unable to attach new org to current session", { userId: session.accountId, newOrgId: org.id, refreshReturnedSession: refreshed != null, verifiedOrgId: verified?.organizationId ?? null, }, ); deleteCookie("wos-session", { path: "/" }); return yield* Effect.fail(new WorkOSError()); } setCookie("wos-session", refreshed, COOKIE_OPTIONS); return { id: org.id, name: org.name }; }), ), );