Repository navigation
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
Draft
Add WorkOS: enterprise SSO sign-in and a webhook-driven copy of organizations and SCIM directories#797robelest wants to merge 4 commits into
robelest wants to merge 4 commits into
Conversation
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.
|
@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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This adds a
workossign-in method and anauthWorkoscomponent. 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/tokenwith 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.
buildStartSignIntakesstartArgs(validators for arguments beyondredirectTo) andauthorizationParams(turns them into extra authorize parameters, or throws to refuse them before anything is written; standard parameters always win). On the client,SignInOptions.startArgspasses them through to the start mutation.allowedOriginsis a function insidebuildStartSignIn, so a method can read its origins when sign-in starts.setupWorkosacceptsstring[] | (() => string[]). GitHub, Google, Apple and generic OAuth wrap their static list.createAuthorizationRequesttakes an optionalcallbackUrl, which must be an absolute http(s) URL. Without it, the component's own<CONVEX_SITE_URL>/callbackis used, as before. This lets an app serve the callback from its own domain through a proxy.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 anonSignInthat throws rolls the sign-in back. The example reads the user withgetAuthUserIdand configuresauth.config.tswithconvexAuthProvider()(#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
startSignInWithWorkos({ redirectTo, organizationId | connectionId, loginHint?, domainHint? }): exactly one oforganizationId(org_…) andconnectionId(conn_…). Arguments are length-checked. The React hook isuseSignInWithWorkos(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.organizations.get/list;organizationDomains.get(by domain or email, returns the organization and its active connection, used for routing) andlist;connections.get/list;directories.get/list;directoryUsers.get/list, filtered by exactly one ofdirectory,organization(optionally withidpId, which matches the SSO profile),idpIdoremail; anddirectoryGroups.get/list. Lists are paginated, and removed rows are skipped.portal.generateLink({ organizationId, intent, returnUrl? }): a short-lived Admin Portal link forsso,dsync,domain_verificationorcertificate_renewal. The link is never stored, and the component does no authorization, so the app must check the caller first.Tables (component
authWorkos)authorizationRequestsandtickets: the shared OAuth tables.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 carriessweepinguntil its contents are gone. There is no membership table, because a directory user'srole/rolesalready reflect group membership.webhook: the deployment's endpoint id, URL, signing secret, and the app'sonEventfunction handle.How it works
Webhook (
<httpPrefix>/webhook):<t>.<body>from theWorkOS-Signatureheader, with a 5-minute timestamp tolerance and a constant-time compare. The webhook answers 401 untilregisterhas run, and 400 for an unreadable body.dsync.group.user_addedanduser_removedread the directory user again.Registration:
webhook.registerfinds 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.unregisterdeletes it.Reconcile (started by
syncWorkos):Retry-Afterretries 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:
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.sweepmutation 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 hearsdsync.deletedbefore the deletions of the directory's users and groups.Configuration
AUTH_WORKOS_CLIENT_IDandAUTH_WORKOS_API_KEYon the deployment. WorkOS authenticates both the code exchange and the REST API with the API key, which is why the key is bound asCLIENT_SECRET.<site-url>/workos/callback, or yourcallbackUrl, as a redirect URI in the WorkOS dashboard.npx convex run auth:syncWorkosafter every deploy, andauth:unregisterWorkosbefore deleting a deployment.The component reads
CONVEX_SITE_URLinside 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,onSignInrefusing a deprovisioned directory user, and a deprovision handler that revokes sessions in batches throughrevokeUserSessions),docs/login-providers/workos.mdx, wiring indoc-examples, and./components/workos/testforconvex-test.Testing
pnpm test(83 files, 1094 tests),pnpm typecheck(every package butargon2id-wasm, whose WASM build CI runs),pnpm lint,pnpm format:check,pnpm check:doc-examples,pnpm check:declarations, and the docs build.pnpm codegenfor the components,react-workosanddoc-examplesleaves no diff.Retry-Afterhandling.core.test.ts: 2 newrevokeUserSessionstests, covering the write-limit split and a single session larger than the limit.Open questions
module: "auth": the component calls the app back through a function handle made frommakeFunctionReference(\${module}:onWorkosEvent`). Passinginternal.auth` makes the module's types circular. Is there a pattern you prefer?revokeUserSessionsvs.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.revokeAllSessionsis only on thectx.convexAuththatauthMutationgives 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), thenrevokeSessionsrunsdeleteSessionon 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 ondeleteSessionnotes) fails the transaction and can't be revoked.revokeUserSessionsstops one write short of the limit and returns{ more }. If a cap likedowski/cap-max-sessions(MAX_SESSIONS_PER_USER = 100) lands,revokeAllSessionsis bounded too, and the two could merge into one helper callable from app mutations. Which would you prefer? And is a hard-codedTRANSACTION_WRITE_LIMIT = 16_000acceptable, or should it come from somewhere else?revokeUserSessions, and the WorkOS component? The commits are already split that way.