864 lines · 36.9 KB · TypeScript
RawDownload
import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi";import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";import { Clock, Duration, Effect, Predicate } from "effect";import { isValidOrgSlug } from "@executor-js/api"; import {  AUTH_PATHS,  CloudAuthApi,  CloudAuthPublicApi,  McpExecutionNotFoundError,  McpSessionForbiddenError,  OrganizationDeletionForbidden,  OrganizationDeletionIncomplete,} from "./api";import { MemberDirectory, NoOrganization } from "@executor-js/api/server";// Pure constants/codec module (no React) — safe in the backend graph.import { AUTH_HINT_COOKIE } from "@executor-js/react/multiplayer/auth-hint";import { SessionContext, SessionCookies } from "./middleware";import { encodeLoginState, decodeLoginState } from "./login-state";import { safeReturnTo } from "./return-to";import { UserStoreService } from "./context";import { mirrorMembership, mirrorSignIn } from "./mirror-feeders";import { env } from "cloudflare:workers";import { WorkOSError } from "./errors";import { WorkOSClient } from "./workos";import { AutumnService, autumnStatusOf } from "../extensions/billing/service";import { forkReportMemberSeats } from "../extensions/billing/member-seats";import { captureCauseEffect } from "../observability";import {  hasPaidOrganizationSubscription,  isOverFreeOrganizationLimit,  shouldApplyFreeOrganizationLimit,} from "../extensions/billing/plans";import { LAST_ORG_COOKIE } from "./last-org-cookie";import {  ORG_SELECTOR_HEADER,  authorizeOrganization,  authorizeOrganizationSelector,  markOrganizationDeleted,  resolveOrganization,  type AuthorizeOrganizationOptions,} from "./organization";import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; 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 randomNonce = (): 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 requestHeaders = Effect.map(HttpServerRequest.HttpServerRequest.asEffect(), (req) => ({  ...req.headers,})); const firstPathSegment = (path: string): string | null => {  const pathname = path.split(/[?#]/, 1)[0] ?? "";  const segment = pathname.split("/")[1];  return segment && isValidOrgSlug(segment) ? segment : null;}; const requestedOrgSelectorFromReturnTo = (returnTo: string): string | null =>  firstPathSegment(returnTo); const selectedOrganization = (options: AuthorizeOrganizationOptions = {}) =>  Effect.gen(function* () {    const session = yield* SessionContext;    const headers = yield* requestHeaders;    const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;    if (!selector) {      return yield* new NoOrganization();    }     const org = yield* authorizeOrganizationSelector(session.accountId, selector, options).pipe(      Effect.catch(() => Effect.fail(new NoOrganization())),    );    if (!org) {      return yield* new NoOrganization();    }     return {      ...session,      organizationId: org.id,      memberRole: org.memberRole,    };  }); const requireSelectedOrganization = selectedOrganization(); const getMcpSessionStub = (mcpSessionId: string) => mcpSessionStub(env.MCP_SESSION, mcpSessionId); const failMcpApprovalResult = (  result: { readonly status: "not_found" | "forbidden" },  params: { readonly mcpSessionId: string; readonly executionId: string },) => {  if (result.status === "forbidden") {    return Effect.fail(new McpSessionForbiddenError({ mcpSessionId: params.mcpSessionId }));  }  return Effect.fail(new McpExecutionNotFoundError({ executionId: params.executionId }));}; 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/logout) + session// (me/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", ({ query }) =>        Effect.gen(function* () {          const workos = yield* WorkOSClient;          // 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 ?? "";          // OAuth round-trips `state` verbatim, so the validated returnTo          // rides inside it next to the CSRF nonce — no extra cookie.          const state = encodeLoginState({            nonce: randomNonce(),            returnTo: safeReturnTo(query.returnTo) ?? undefined,          });          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* WorkOSClient;          const users = yield* UserStoreService;          // Hosted invitations can start at WorkOS without app-issued state.          // Discard that unbound code and start a fresh browser-bound login.          // Exchanging it here would allow login CSRF.          if (query.state === undefined) {            return deleteResponseCookie(              HttpServerResponse.redirect(AUTH_PATHS.login, { status: 302 }),              STATE_COOKIE,            );          }           const cookieState = request.cookies[STATE_COOKIE] ?? null;          // Only exchange codes bound to the state cookie set on /login.          if (!cookieState || !timingSafeEqual(cookieState, query.state)) {            return deleteResponseCookie(              HttpServerResponse.text("Invalid login state", { status: 400 }),              STATE_COOKIE,            );          }           const result = yield* workos.authenticateWithCode(query.code);           // ONE membership list for the whole callback. It feeds the mirror          // (the user + every org they hold a membership in, all already in          // hand) and it is the membership check for every landing-org          // candidate below, so the callback makes no per-candidate WorkOS          // call. The user's account row is minted by the mirror's user          // upsert. The list's fetch instant, taken before the read, stamps          // the organization names it carries (see `mirrorSignIn`).          const fetchedAt = new Date(yield* Clock.currentTimeMillis);          const memberships = yield* workos.listUserMemberships(result.user.id);          yield* mirrorSignIn(result.user, memberships.data, fetchedAt);           let sealedSession = result.sealedSession;           // Resume where the SSR gate interrupted them. The state passed the          // CSRF check above, but it's still a          // round-tripped value, so the returnTo inside it is re-validated like          // any other untrusted path.          const returnTo = safeReturnTo(decodeLoginState(query.state)?.returnTo) ?? "/";          const requestedOrgSelector = requestedOrgSelectorFromReturnTo(returnTo);           // An org SLUG (both candidate sources below are slug-validated, so          // an `org_…` id never reaches here) resolves to its id only when the          // list above holds an ACTIVE membership in it. Pending memberships          // are skipped because refreshing into one 400s and would bypass          // invite consent. A slug that fails to resolve (unknown, or a store          // hiccup) is not a candidate, the same as an org the user is not in.          const activeOrganizationIds = new Set(            memberships.data.filter((m) => m.status === "active").map((m) => m.organizationId),          );          const activeOrganizationFor = (slug: string) =>            users              .use("getOrganizationBySlug", (s) => s.getOrganizationBySlug(slug))              .pipe(                Effect.map((org) => (org && activeOrganizationIds.has(org.id) ? org.id : null)),                Effect.orElseSucceed(() => null),              );           // Prefer the org in the URL that sent the user to login. If the URL          // is bare, or not an org route, prefer the org this browser last          // worked in (the last-org cookie — it outlives the session precisely          // so a fresh login lands where the user left off), then WorkOS's          // org, then the first active membership for org-less sessions.          // The cookie is membership-checked like any selector, so a stale          // one just falls through.          let targetOrganizationId = requestedOrgSelector            ? yield* activeOrganizationFor(requestedOrgSelector)            : null;          if (!targetOrganizationId && !requestedOrgSelector) {            const lastOrgSlug = request.cookies[LAST_ORG_COOKIE];            targetOrganizationId =              lastOrgSlug && isValidOrgSlug(lastOrgSlug)                ? yield* activeOrganizationFor(lastOrgSlug)                : null;          }          targetOrganizationId ??= result.organizationId ?? null;          if (!targetOrganizationId && !requestedOrgSelector) {            const existingActive = memberships.data.find((m) => m.status === "active");            targetOrganizationId = existingActive?.organizationId ?? null;          }           // Seat changes the app never sees a mutation for (invitation          // acceptance in AuthKit, SSO JIT provisioning, join by domain,          // WorkOS dashboard edits) all end in a sign-in, so every login          // reconciles the landed org's billed seat count. Forked: billing          // must not delay the login.          if (targetOrganizationId) {            yield* forkReportMemberSeats(targetOrganizationId);          }           if (            targetOrganizationId &&            targetOrganizationId !== result.organizationId &&            sealedSession          ) {            // Best-effort refresh: if WorkOS rejects, fall through with the            // original session instead of 500ing the entire callback.            const refreshed = yield* workos              .refreshSession(sealedSession, targetOrganizationId)              .pipe(Effect.orElseSucceed(() => null));            if (refreshed) sealedSession = refreshed;          }           if (!sealedSession) {            return HttpServerResponse.text("Failed to create session", {              status: 500,            });          }           return deleteResponseCookie(            setResponseCookie(              HttpServerResponse.redirect(returnTo, { status: 302 }),              "wos-session",              sealedSession,              RESPONSE_COOKIE_OPTIONS,            ),            STATE_COOKIE,          );        }),      )      .handleRaw("logout", ({ request }) =>        Effect.gen(function* () {          const workos = yield* WorkOSClient;          // The session this browser presents, NOT one the middleware vouched          // for — signing out of a session that has already ended must still          // sign the browser out (see the group declaration in ./api.ts).          const sealedSession = request.cookies["wos-session"] ?? "";           // WorkOS's documented sign-out: send the browser through the WorkOS          // logout endpoint, which ends the AuthKit session upstream and then          // redirects to the registered sign-out URL. Without this hop, the          // hosted session survives and the next "Sign in" silently          // re-authenticates (issue #1445). Fail-open when the cookie won't          // unseal — there is then nothing to end upstream, and local sign-out          // must still complete, so fall back to "/".          const origin = env.VITE_PUBLIC_SITE_URL ?? "";          const logoutUrl = sealedSession            ? yield* workos.logoutUrl(sealedSession, origin ? `${origin}/` : undefined)            : null;           const response = HttpServerResponse.redirect(logoutUrl ?? "/", {            status: 302,          });           // Drop only what this browser actually presented. Both cookies are          // SameSite=Lax, so a cross-site form POST carries neither — it gets          // the bare redirect and cannot be used to sign anyone out.          if (!sealedSession && request.cookies[AUTH_HINT_COOKIE] === undefined) return response;           // The auth-hint travels with the session: leaving it behind would          // make the next page load optimistically paint the app shell for a          // signed-out browser.          return deleteResponseCookie(            deleteResponseCookie(              HttpServerResponse.setHeader(response, "Clear-Site-Data", '"cache", "storage"'),              "wos-session",            ),            AUTH_HINT_COOKIE,          );        }),      )      // CLI device-login discovery. The WorkOS device endpoints live on the      // WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production,      // the SAME base the SDK uses, so e2e points the CLI at the emulator with      // zero extra wiring). The CLI runs RFC 8628 against them as a public      // client (no secret) and gets a WorkOS access-token JWT back.      .handle("cliLogin", () =>        Effect.sync(() => {          const base = (env.WORKOS_API_URL ?? "https://api.workos.com").replace(/\/+$/, "");          return {            provider: "workos" as const,            deviceAuthorizationEndpoint: `${base}/user_management/authorize/device`,            tokenEndpoint: `${base}/user_management/authenticate`,            clientId: env.WORKOS_CLIENT_ID,          };        }),      ),); // ---------------------------------------------------------------------------// 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, slug: org.slug } : null,          };        }),      )      .handle("organizations", () =>        Effect.gen(function* () {          const directory = yield* MemberDirectory;          const session = yield* SessionContext;           // The caller's memberships (active + pending, as WorkOS listed them          // before) from the local mirror — one indexed read, no WorkOS call.          const memberships = yield* directory.membershipsOf(session.accountId);          // Resolve through the mirror (not WorkOS directly) so each org's          // URL slug is minted/read — the switcher navigates to `/<slug>`.          // An org marked deleted (its deletion is in progress or failed          // part-way, see deleteOrganization) refuses every session, so it          // is not a place the switcher can go.          const organizations = yield* Effect.all(            memberships.map((m) =>              resolveOrganization(m.organizationId).pipe(                Effect.map((org) =>                  org.deletedAt === null                    ? {                        id: org.id,                        name: org.name,                        slug: org.slug,                      }                    : null,                ),                Effect.orElseSucceed(() => null),              ),            ),            { concurrency: "unbounded" },          );           return {            organizations: organizations.filter(Predicate.isNotNull),            activeOrganizationId: session.organizationId,          };        }),      )      .handle("createOrganization", ({ payload }) =>        Effect.gen(function* () {          const workos = yield* WorkOSClient;          const users = yield* UserStoreService;          const session = yield* SessionContext;          const autumn = yield* AutumnService;           const name = payload.name.trim();          // The free-organizations-per-user limit counts the caller's ACTIVE          // memberships, read from the local mirror.          const directory = yield* MemberDirectory;          const activeMemberships = yield* directory.membershipsOf(session.accountId, ["active"]);           if (isOverFreeOrganizationLimit(activeMemberships)) {            const paidOrganizationIds = yield* Effect.all(              activeMemberships.map((membership) =>                autumn                  .use((client) =>                    client.customers.getOrCreate({                      customerId: membership.organizationId,                    }),                  )                  .pipe(                    Effect.map((customer) =>                      hasPaidOrganizationSubscription(customer.subscriptions)                        ? membership.organizationId                        : null,                    ),                  ),              ),              { concurrency: 3 },            ).pipe(              // Any Autumn failure here (outage or missing customer) leaves the              // paid/free split unknown, and the limit must fail closed.              Effect.mapError(() => new WorkOSError()),              Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))),            );             if (shouldApplyFreeOrganizationLimit(activeMemberships, paidOrganizationIds)) {              return yield* new WorkOSError();            }          }           const org = yield* workos.createOrganization(name);          const membership = yield* workos.createMembership(org.id, session.accountId, "admin");          // `upsertOrganization` mints the slug at insert — no separate heal step.          const mirrored = yield* users.use("upsertOrganization", (s) =>            s.upsertOrganization({              id: org.id,              name: org.name,              updatedAt: new Date(org.updatedAt),            }),          );          // Write-through: the creator's admin membership, from the create          // response, lands in the mirror before anything reads it.          yield* mirrorMembership(membership);           // Provision the org's billing customer while we're the ones creating          // the org. Without this the first billing call an org ever makes is a          // non-creating one (balance check / usage track), which 404s and keeps

Lines 1–500 of 864.