Skip to content

Add WorkOS: enterprise SSO sign-in and a webhook-driven copy of organizations and SCIM directories - #797

Draft
robelest wants to merge 4 commits into
convex:rebootfrom
estifanos-sh:workos-pr
Draft

robelest wants to merge 4 commits into
convex:rebootfrom
estifanos-sh:workos-pr

Conversation

@robelest

@robelest robelest commented Oct 31, 2026 •

Copy link
Copy Markdown

This adds a workos sign-in method and an authWorkos component. The method gives enterprise SSO through WorkOS. The component keeps a copy of the WorkOS environment (organizations, domains, SSO connections, directories, directory users and groups) up to date from WorkOS webhooks. A production app runs it to send users to their organization's IdP by email domain, and to sign a user out everywhere within seconds of their SCIM directory deactivating or deleting them.

It uses WorkOS's standalone SSO API, not AuthKit (User Management). WorkOS creates no users or sessions of its own, so the session stays Convex Auth's. The code is exchanged at /sso/token with the environment's API key, and identity comes from /sso/profile, so the shared OAuth callback is unchanged.

Changes to shared OAuth code

These are small and backwards compatible. Existing methods pass the same values as before.

  • buildStartSignIn takes startArgs (validators for arguments beyond redirectTo) and authorizationParams (turns them into extra authorize parameters, or throws to refuse them before anything is written; standard parameters always win). On the client, SignInOptions.startArgs passes them through to the start mutation.
  • allowedOrigins is a function inside buildStartSignIn, so a method can read its origins when sign-in starts. setupWorkos accepts string[] | (() => string[]). GitHub, Google, Apple and generic OAuth wrap their static list.
  • createAuthorizationRequest takes an optional callbackUrl, which must be an absolute http(s) URL. Without it, the component's own <CONVEX_SITE_URL>/callback is used, as before. This lets an app serve the callback from its own domain through a proxy.
  • The core gets public.revokeUserSessions({ userId }) -> { more }. It deletes as many of a user's sessions and refresh tokens as fit in one transaction, stopping one write short of Convex's write limit (16,000) so the caller can schedule a continuation. A session with more tokens than that is deleted over several calls.

Sessions

The method uses the stateful session model (#757, #759, #779, #760) as it is: the shared OAuth callback redeems a ticket through the core's signUp/signIn, which mints the session, and an onSignIn that throws rolls the sign-in back. The example reads the user with getAuthUserId and configures auth.config.ts with convexAuthProvider() (#788). Because a JWT now needs a live session, deleting a user's sessions signs them out at once, not when their access token expires, which is what lets a SCIM deprovision take effect within seconds.

Public API

const workos = setupWorkos(auth, {
  component: components.authWorkos,
  module: "auth", // the module that exports onWorkosEvent
  allowedRedirectOrigins: ["https://app.example.com"], // or () => string[]
  callbackUrl: () => "https://app.example.com/workos/callback", // optional
});

export const { startSignInWithWorkos, completeSignInWithWorkos } =
  workos.attachUserCallbacks({ createUser, onSignIn });

export const { syncWorkos, unregisterWorkos, onWorkosEvent } =
  workos.attachDirectoryCallbacks<DataModel>({
    "dsync.user.updated": async (ctx, user) => { /* ... */ },
    "dsync.user.deleted": async (ctx, user) => { /* ... */ },
  });
  • startSignInWithWorkos({ redirectTo, organizationId | connectionId, loginHint?, domainHint? }): exactly one of organizationId (org_…) and connectionId (conn_…). Arguments are length-checked. The React hook is useSignInWithWorkos (methods/workos/react).
  • vWorkosProfile: { id, idpId, organizationId?, connectionId, connectionType, email (lowercased), firstName?, lastName? }. The docs warn that the IdP asserts the email and WorkOS does not verify it.
  • attachDirectoryCallbacks: handlers keyed by WorkOS event name, each given the object as WorkOS holds it now (or, for a deletion, as the copy last held it). It returns:
    • syncWorkos: an internal action to run on every deploy. It registers the webhook endpoint, then starts a reconcile.
    • unregisterWorkos: run before a deployment is deleted.
    • onWorkosEvent: the internal mutation the component calls through a function handle.
  • Reads on the component: organizations.get/list; organizationDomains.get (by domain or email, returns the organization and its active connection, used for routing) and list; connections.get/list; directories.get/list; directoryUsers.get/list, filtered by exactly one of directory, organization (optionally with idpId, which matches the SSO profile), idpId or email; and directoryGroups.get/list. Lists are paginated, and removed rows are skipped.
  • portal.generateLink({ organizationId, intent, returnUrl? }): a short-lived Admin Portal link for sso, dsync, domain_verification or certificate_renewal. The link is never stored, and the component does no authorization, so the app must check the caller first.

Tables (component authWorkos)

  • authorizationRequests and tickets: the shared OAuth tables.
  • One table per WorkOS object, with WorkOS's field names in camelCase: organizations, organizationDomains, connections, directories, directoryUsers, directoryGroups. Each row is a union: the object plus { generation, updatedAt }, or a removal { id, deleted: true, generation }. A deleted directory also carries sweeping until its contents are gone. There is no membership table, because a directory user's role/roles already reflect group membership.
  • webhook: the deployment's endpoint id, URL, signing secret, and the app's onEvent function handle.

How it works

Webhook (<httpPrefix>/webhook):

  • Signature check: HMAC-SHA256 of <t>.<body> from the WorkOS-Signature header, with a 5-minute timestamp tolerance and a constant-time compare. The webhook answers 401 until register has run, and 400 for an unreadable body.
  • A verified delivery is not applied from its payload. It names an object, and that object is read again from WorkOS by id. It is then written as it is now, or removed if WorkOS answers 404. Delivery order therefore doesn't matter.
  • dsync.group.user_added and user_removed read the directory user again.
  • An organization delivery also re-reads, by id, any domain the copy holds that the organization no longer lists.
  • A handler that throws fails the delivery with 500, and WorkOS retries it.

Registration:

  • webhook.register finds this deployment's endpoint by URL, paging the Webhooks API. If there is none, it creates one subscribed to the events the copy applies. If there is one, it re-enables it and adds any missing events.
  • It stores the endpoint's signing secret, so there is no webhook-secret env var.
  • Re-running it never creates a second endpoint. Each preview deployment registers its own endpoint, and unregister deletes it.

Reconcile (started by syncWorkos):

  • It takes its start time T as its generation, then lists organizations, connections and directories, and each directory's users and groups.
  • It reads one page of 50 per action call and writes it in one mutation. After 100 pages it hands over to a fresh action.
  • Then it prunes, children first. Each row whose generation is older than T is read again by id, and is removed only if WorkOS answers 404. Overlapping reconciles, and deliveries that land during a reconcile, therefore never remove an object WorkOS still has.
  • A WorkOS refusal with Retry-After retries the same step after that delay. Any other failure ends the run, and the next delivery or the next deploy's sync picks the work up.

Ordering and deletion guarantees:

  • Each row stores WorkOS's updated_at. A read older than the stored version is dropped, so a delivery, page or prune read that commits late never overwrites a newer one.
  • A removal is a tombstone, and no later read writes the object back. This is safe because WorkOS never reuses ids. A delete for an id the copy never held still records its tombstone, so a read from before the delete cannot insert the object.
  • A deleted directory becomes a tombstone at once. A self-rescheduling sweep mutation then removes its users and groups 100 at a time. The tombstone stays afterwards, so a late listing cannot bring the directory or its contents back. The app hears dsync.deleted before the deletions of the directory's users and groups.
  • A domain an organization no longer lists is removed only after a by-id read answers 404.
  • Each handler runs in the same transaction as the write it describes, so the app's data and the copy never disagree. A write that changes nothing emits no event.

Configuration

// convex.config.ts
const app = defineApp({
  env: { AUTH_WORKOS_CLIENT_ID: v.string(), AUTH_WORKOS_API_KEY: v.string() },
});
app.use(auth);
app.use(workos, {
  httpPrefix: "/workos",
  env: {
    CLIENT_ID: app.env.AUTH_WORKOS_CLIENT_ID,
    CLIENT_SECRET: app.env.AUTH_WORKOS_API_KEY,
  },
});
  1. Set AUTH_WORKOS_CLIENT_ID and AUTH_WORKOS_API_KEY on the deployment. WorkOS authenticates both the code exchange and the REST API with the API key, which is why the key is bound as CLIENT_SECRET.
  2. Add <site-url>/workos/callback, or your callbackUrl, as a redirect URI in the WorkOS dashboard.
  3. Run npx convex run auth:syncWorkos after every deploy, and auth:unregisterWorkos before deleting a deployment.

The component reads CONVEX_SITE_URL inside its own functions. Cloud backends support that. Self-hosted backends need convex-backend@64c163a or later, the same requirement the OAuth callback already has.

Also included: examples/react-workos (routing by email domain, onSignIn refusing a deprovisioned directory user, and a deprovision handler that revokes sessions in batches through revokeUserSessions), docs/login-providers/workos.mdx, wiring in doc-examples, and ./components/workos/test for convex-test.

Testing

  • The full gates on Node 24 (the CI version): pnpm test (83 files, 1094 tests), pnpm typecheck (every package but argon2id-wasm, whose WASM build CI runs), pnpm lint, pnpm format:check, pnpm check:doc-examples, pnpm check:declarations, and the docs build. pnpm codegen for the components, react-workos and doc-examples leaves no diff.
  • The WorkOS component and method plus the OAuth client: 6 files, 79 tests.
    • What they cover: authorize parameters and routing refusals; the callback exchange; signature verification and replay tolerance; register/unregister idempotence; deliveries in any order; membership re-reads; tombstones blocking late writes; directory sweep; overlapping reconciles; prune only on 404; and the Retry-After handling.
  • core.test.ts: 2 new revokeUserSessions tests, covering the write-limit split and a single session larger than the limit.
  • A real Convex deployment ran it end to end against a WorkOS Staging environment: SSO sign-in, webhook registration, deliveries, reconcile and prune.

Open questions

  1. module: "auth": the component calls the app back through a function handle made from makeFunctionReference(\${module}:onWorkosEvent`). Passing internal.auth` makes the module's types circular. Is there a pattern you prefer?
  2. revokeUserSessions vs. ctx.convexAuth.revokeAllSessions (Revoke all of a user's sessions on email password recovery #779): a directory handler has to sign out a user who may hold many sessions, in one of the app's own mutations. revokeAllSessions is only on the ctx.convexAuth that authMutation gives methods (server/setup.ts, makeHelpers), and it does the whole revoke in the caller's transaction: listSessions .collect()s every session of the user (component/public.ts), then revokeSessions runs deleteSession on each, which .collect()s and deletes every refresh token of that session. Nothing bounds the writes, so a user with enough sessions (or one session refreshed often enough, as the comment on deleteSession notes) fails the transaction and can't be revoked. revokeUserSessions stops one write short of the limit and returns { more }. If a cap like dowski/cap-max-sessions (MAX_SESSIONS_PER_USER = 100) lands, revokeAllSessions is bounded too, and the two could merge into one helper callable from app mutations. Which would you prefer? And is a hard-coded TRANSACTION_WRITE_LIMIT = 16_000 acceptable, or should it come from somewhere else?
  3. Scope: WorkOS's standalone SSO API only, with no AuthKit. Is that the integration you want in the package, or would you rather have AuthKit, or both?
  4. Shape: is one table per WorkOS object with tombstone unions acceptable, or would you rather tombstones be purged after a horizon? They are kept forever today, and they are small (an id and a number).
  5. Split: should this be one PR, or three: the shared OAuth changes, revokeUserSessions, and the WorkOS component? The commits are already split that way.

buildStartSignIn takes startArgs, validators for arguments beyond
redirectTo, and authorizationParams, which turns them into extra
authorize parameters or throws to refuse them before anything is
written; the standard parameters always win. On the client,
SignInOptions.startArgs passes them to the start mutation.

allowedOrigins is read when sign-in starts, so a method may take its
origins as a function. createAuthorizationRequest takes an optional
absolute callbackUrl; without it the component's own callback route
under CONVEX_SITE_URL is used, as before. GitHub, Google, Apple and
generic OAuth pass the same values as before.
public.revokeUserSessions({ userId }) deletes as many of the user's
sessions and their refresh tokens as one transaction can write,
counting rows as it reads them, and stops one write short of the limit
so the caller can schedule its continuation. It returns { more }. A
session whose tokens alone pass the limit is deleted over several
calls.
…CIM directories

The workos method signs in through WorkOS's standalone SSO API, routed
to exactly one organization or connection. The code is exchanged at
/sso/token with the API key and the identity read from /sso/profile,
so the shared OAuth callback is unchanged and the session stays
Convex Auth's.

The authWorkos component keeps a copy of the environment's
organizations, organization domains, SSO connections, directories,
directory users and directory groups, one table per WorkOS object.
A signed webhook delivery names an object, which is read again from
WorkOS by id and written as it is now, or removed on a 404, so
deliveries apply in any order. Each row keeps WorkOS's updated_at and a
removal stays as a tombstone, so a late read never overwrites a newer
version or brings a removed object back. A deleted directory's users
and groups are swept in batches.

webhook.register creates or repairs this deployment's endpoint at
WorkOS and keeps its signing secret; unregister deletes it. A
reconcile, started by syncWorkos on deploy, lists everything WorkOS
holds and removes a row its listings did not reach only once WorkOS
answers 404 for it. portal.generateLink returns Admin Portal links.

setupWorkos(...).attachDirectoryCallbacks takes handlers keyed by
WorkOS event name, run in the transaction of the write they describe.
examples/react-workos routes sign-in by email domain, refuses a
deprovisioned directory user in onSignIn, and signs a deprovisioned
user out everywhere through revokeUserSessions.
@vercel

vercel Bot commented Oct 31, 2026

Copy link
Copy Markdown

@robelest is attempting to deploy a commit to the Convex Team on Vercel.

A member of the Team first needs to authorize it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant