Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions apps/cloud/src/api/protected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { McpExtensionService } from "@executor/plugin-mcp/api";
import { GoogleDiscoveryExtensionService } from "@executor/plugin-google-discovery/api";
import { GraphqlExtensionService } from "@executor/plugin-graphql/api";

import { UserStoreService } from "../auth/context";
import { authorizeOrganization } from "../auth/authorize-organization";
import { WorkOSAuth } from "../auth/workos";
import { AutumnService } from "../services/autumn";
import { createOrgExecutor } from "../services/executor";
Expand All @@ -34,8 +34,7 @@ const lookupOrgForRequest = (request: HttpServerRequest.HttpServerRequest) =>
const session = yield* workos.authenticateRequest(webRequest);
if (!session || !session.organizationId) return null;

const users = yield* UserStoreService;
return yield* users.use((s) => s.getOrganization(session.organizationId!));
return yield* authorizeOrganization(session.userId, session.organizationId);
});

const createProtectedApp = (organizationId: string, organizationName: string) =>
Expand Down
7 changes: 6 additions & 1 deletion apps/cloud/src/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,12 @@ export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic")

/** Session auth endpoints — require a logged-in user, may not have an org */
export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")
.add(HttpApiEndpoint.get("me")`/auth/me`.addSuccess(AuthMeResponse).addError(UserStoreError))
.add(
HttpApiEndpoint.get("me")`/auth/me`
.addSuccess(AuthMeResponse)
.addError(UserStoreError)
.addError(WorkOSError),
)
.add(HttpApiEndpoint.post("logout")`/auth/logout`)
.add(
HttpApiEndpoint.get("organizations")`/auth/organizations`
Expand Down
36 changes: 36 additions & 0 deletions apps/cloud/src/auth/authorize-organization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// ---------------------------------------------------------------------------
// Organization authorization — live membership check against WorkOS.
// ---------------------------------------------------------------------------
//
// The sealed session cookie carries an organizationId that WorkOS signed at
// login / refresh time. WorkOS does NOT invalidate existing sessions when a
// membership is revoked, and `session.authenticate()` validates the JWT
// locally without hitting the API — so a removed user keeps full access
// until their access token naturally expires (~10 min).
//
// To close that gap we verify membership live on every protected request.
// `listUserMemberships` is one WorkOS call per request. If this becomes a
// hot path we can layer a short per-(user, org) TTL cache underneath, or
// swap it for a local memberships table fed by the WorkOS Events API.
//
// Returns the resolved organization (via resolveOrganization) if the user
// currently holds an *active* membership in it, otherwise null. Callers
// should treat null as "no access" and route accordingly (onboarding page /
// 403).

import { Effect } from "effect";

import { resolveOrganization } from "./resolve-organization";
import { WorkOSAuth } from "./workos";

export const authorizeOrganization = (userId: string, organizationId: string) =>
Effect.gen(function* () {
const workos = yield* WorkOSAuth;
const memberships = yield* workos.listUserMemberships(userId);
const active = memberships.data.find(
(m) => m.organizationId === organizationId && m.status === "active",
);
if (!active) return null;

return yield* resolveOrganization(organizationId);
});
76 changes: 38 additions & 38 deletions apps/cloud/src/auth/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ 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 { WorkOSError } from "./errors";
import { WorkOSAuth } from "./workos";
import { server } from "../env";

Expand Down Expand Up @@ -54,40 +56,22 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group(
yield* users.use((s) => s.ensureAccount(result.user.id));

let sealedSession = result.sealedSession;
let organizationId = result.organizationId;

// If the auth response doesn't include an org, check if the user
// already belongs to one. Only create a new organization if they truly
// have no memberships — this prevents duplicate orgs on re-login.
if (!organizationId) {
// 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) {
organizationId = existing.organizationId;
} else {
const name =
[result.user.firstName, result.user.lastName].filter(Boolean).join(" ") ||
result.user.email;
const org = yield* workos.createOrganization(`${name}'s Organization`);
yield* workos.createMembership(org.id, result.user.id, "admin");
yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name }));
organizationId = org.id;
}

// Refresh the session so it includes the org context
if (sealedSession) {
const refreshed = yield* workos.refreshSession(sealedSession, organizationId);
const refreshed = yield* workos.refreshSession(
sealedSession,
existing.organizationId,
);
if (refreshed) sealedSession = refreshed;
}
} else {
const org = yield* workos.getOrganization(organizationId!);
yield* users.use((s) =>
s.upsertOrganization({
id: organizationId!,
name: org.name,
}),
);
}

if (!sealedSession) {
Expand All @@ -96,12 +80,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group(

setCookie("wos-session", sealedSession, COOKIE_OPTIONS);
return HttpServerResponse.redirect("/", { status: 302 });
}).pipe(
Effect.catchTags({
WorkOSError: () => Effect.succeed(HttpServerResponse.redirect("/", { status: 302 })),
UserStoreError: () => Effect.succeed(HttpServerResponse.redirect("/", { status: 302 })),
}),
),
}),
),
);

Expand All @@ -117,9 +96,8 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
.handle("me", () =>
Effect.gen(function* () {
const session = yield* SessionContext;
const users = yield* UserStoreService;
const org = session.organizationId
? yield* users.use((s) => s.getOrganization(session.organizationId!))
? yield* authorizeOrganization(session.accountId, session.organizationId)
: null;

return {
Expand Down Expand Up @@ -184,11 +162,33 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
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);
if (refreshed) {
setCookie("wos-session", refreshed, COOKIE_OPTIONS);
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* new WorkOSError();
}

setCookie("wos-session", refreshed, COOKIE_OPTIONS);
return { id: org.id, name: org.name };
}),
),
Expand Down
30 changes: 30 additions & 0 deletions apps/cloud/src/auth/resolve-organization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// ---------------------------------------------------------------------------
// Organization lookup — local mirror with lazy WorkOS fallback.
// ---------------------------------------------------------------------------
//
// We keep a minimal local mirror of organizations so domain tables can
// foreign-key against them and so we don't hit WorkOS on every request.
// But the mirror can drift: a user's session can reference an org that was
// created outside this app (or before the mirror existed). Rather than
// proactively mirroring on every login — which was the source of the messy
// callback flow we just untangled — we mirror lazily the first time an
// unknown org is read. All other callers just do `getOrganization` and get
// a self-healing lookup for free.

import { Effect } from "effect";

import { UserStoreService } from "./context";
import { WorkOSAuth } from "./workos";

export const resolveOrganization = (organizationId: string) =>
Effect.gen(function* () {
const users = yield* UserStoreService;
const existing = yield* users.use((s) => s.getOrganization(organizationId));
if (existing) return existing;

const workos = yield* WorkOSAuth;
const fresh = yield* workos.getOrganization(organizationId);
return yield* users.use((s) =>
s.upsertOrganization({ id: fresh.id, name: fresh.name }),
);
});
5 changes: 5 additions & 0 deletions apps/cloud/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ExecutorProvider } from "@executor/react/api/provider";
import { Toaster } from "@executor/react/components/sonner";
import { AuthProvider, useAuth } from "../web/auth";
import { LoginPage } from "../web/pages/login";
import { OnboardingPage } from "../web/pages/onboarding";
import { Shell } from "../web/shell";
import appCss from "@executor/react/globals.css?url";

Expand Down Expand Up @@ -82,6 +83,10 @@ function AuthGate() {
return <LoginPage />;
}

if (auth.organization == null) {
return <OnboardingPage />;
}

return (
<AutumnProvider pathPrefix="/api/autumn">
<ExecutorProvider>
Expand Down
91 changes: 91 additions & 0 deletions apps/cloud/src/web/components/create-organization-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { useState } from "react";
import { useAtomSet } from "@effect-atom/atom-react";
import { Input } from "@executor/react/components/input";
import { Label } from "@executor/react/components/label";

import { createOrganization } from "../auth";

type CreatedOrganization = { id: string; name: string };

export function useCreateOrganizationForm(options: {
defaultName?: string;
onSuccess: (org: CreatedOrganization) => void;
onFailure?: () => void;
}) {
const doCreate = useAtomSet(createOrganization, { mode: "promiseExit" });
const [name, setName] = useState(options.defaultName ?? "");
const [error, setError] = useState<string | null>(null);
const [creating, setCreating] = useState(false);

const reset = (nextName = options.defaultName ?? "") => {
setName(nextName);
setError(null);
setCreating(false);
};

const submit = async () => {
const trimmed = name.trim();
if (!trimmed) {
setError("Organization name is required.");
return;
}
setCreating(true);
setError(null);
const exit = await doCreate({ payload: { name: trimmed } });
setCreating(false);
if (exit._tag === "Success") {
options.onSuccess(exit.value);
} else {
setError("Failed to create organization.");
options.onFailure?.();
}
};

return {
name,
setName,
error,
setError,
creating,
submit,
reset,
canSubmit: name.trim().length > 0,
};
}

export function CreateOrganizationFields(props: {
name: string;
onNameChange: (name: string) => void;
error: string | null;
onSubmit: () => void;
}) {
return (
<div className="grid gap-4 py-3">
<div className="grid gap-1.5">
<Label
htmlFor="organization-name"
className="text-sm font-medium uppercase tracking-wider text-muted-foreground"
>
Organization name
</Label>
<Input
id="organization-name"
value={props.name}
placeholder="Northwind Labs"
autoFocus
onChange={(event) => props.onNameChange((event.target as HTMLInputElement).value)}
onKeyDown={(event) => {
if (event.key === "Enter") props.onSubmit();
}}
className="h-9 text-sm"
/>
</div>

{props.error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
<p className="text-sm text-destructive">{props.error}</p>
</div>
)}
</div>
);
}
Loading
Loading