@codex review
Linear: MCPG-189
Adds a per-org OAuth2 token endpoint at /api/scim/token/<orgId> that accepts a standard client_credentials request and injects the org's SCIM scope server-side before forwarding to FusionAuth. The displayed tokenEndpoint in the SCIM credentials panel now points at this shim instead of FusionAuth directly.
Okta's custom-SCIM-app OAuth 2.0 mode does not let customers send a scope parameter in client_credentials requests. FusionAuth's SCIM authorization is scope-bound (target-entity:{serverEntityId}:{permissions}), so without scope the issued token cannot reach SCIM endpoints. Until now, Okta customers were forced into HTTP Header auth where they had to manually mint a bearer token via curl and paste it — and that token expired after ~1h, breaking SCIM until they pasted a fresh one. JumpCloud, OneLogin, and other custom SCIM connectors hit the same wall.
With the shim, the IdP sees a normal OAuth2 token endpoint and refreshes on its own. Every IdP (including Okta) gets the same hands-off UX that Entra has today.
Retry-After. Bounded eviction (sweep expired buckets first, then drop oldest insertions only if still at capacity) — no global clears that would lift throttling on unrelated orgs.${AUTH_PUBLIC_URL}/api/scim/token/<orgId>) as the tokenEndpoint, so the shim URL surfaces both at first credentials generation AND on subsequent page loads for already-configured orgs.orgAdminProcedure for the HTTP-Header fallback when an IdP genuinely cannot speak OAuth2. The button only renders inside the post-regenerate disclosure card (same lifecycle as the client secret)./oauth2/token still works, so customers already configured in HTTP-Header mode keep functioning until they re-enter SCIM settings to switch.After two review passes (the second by Codex), the following hardening landed in this PR:
Content-Type validation now parses the media-type essence strictly (rejects e.g. multipart/form-data; foo=application/x-www-form-urlencoded that a substring check would have accepted).Authorization: Basic prefix is matched case-insensitively per RFC 7617.AbortSignal.timeout(5000) and a 16KB upstream body cap.expires_in from FusionAuth is parsed through a Zod schema with bounds (positive integer, ≤ 7 days)./api/scim/token/<orgId> at 60 req/min can lock out the legit IdP for that org until the next window. Properly closing this would require IP-bucketing shared across function instances (Redis or another shared store), which is intentionally deferred to keep this PR DB- and infra-clean. Mitigations in place today: per-org limit caps blast radius, FusionAuth's own rate limits sit underneath, and the 401 response shape is identical regardless of whether the orgId exists (no enumeration signal). Worth adding alerting on sustained 429/invalid_client per orgId as a separate ticket.
org_admin (Alice), open Settings → SSO → SCIM Provisioning.Token Endpoint is <AUTH_PUBLIC_URL>/api/scim/token/<orgId> (not the FusionAuth URL).Token Endpoint still displays the shim URL via getSsoConfig.Retry-After header).@codex review
@codex review
The latest updates on your projects. Learn more about Vercel for GitHub.
| Project | Deployment | Actions | Updated (UTC) |
|---|---|---|---|
| composio-enterprise-admin | Preview | May 18, 2026 9:08pm | |
| composio-enterprise-internal | May 18, 2026 9:08pm |
Today's SCIM credentials flow has different UX depending on the IdP:
clientId, clientSecret, tokenEndpoint, scope into Entra's provisioning form, Entra does the OAuth2 client_credentials exchange against FusionAuth itself, refreshes on its own.scope parameter in the request. FusionAuth's SCIM authorization is scope-bound (), so without scope the issued token can't reach SCIM endpoints. Customers are forced into Okta's "HTTP Header" mode where they manually mint a bearer token via and paste it. That token expires (typically 1h) so SCIM eventually breaks until they paste a fresh one.client_credentialstarget-entity:{serverEntityId}:{permissions}curlRoot cause: FusionAuth requires scope; Okta won't send scope. Same shape will hit JumpCloud, OneLogin, and other custom-SCIM-app integrations.
Stop pointing IdPs at FusionAuth's /oauth2/token. Expose https://egateway.composio.dev/api/scim/token from the admin app. It accepts a stock OAuth2 client_credentials request (no scope needed from the IdP), looks up the right scope server-side from the org's SCIM configuration, and proxies into FusionAuth's token endpoint with the scope injected. The IdP sees a normal OAuth2 token endpoint and refreshes on its own. Same UX as Entra has today — for every IdP.
This is purely additive. Existing customers in Okta HTTP-Header mode keep working until they re-enter SCIM settings and switch to OAuth mode with the new URL.
apps/admin/app/api/scim/token/route.ts (public, no admin auth middleware; modeled on the existing /api/oauth/token pattern).POST application/x-www-form-urlencoded. Honor both auth styles per RFC 6749 §2.3.1 (HTTP Basic header or body credentials).grant_type=client_credentials; anything else → 400 unsupported_grant_type.client_id → orgId + scope at request time without a DB schema change:
GET /api/entity/search?queryString=clientId:<value> against FusionAuth → returns the SCIM client entity.prisma.organization.findUnique({ where: { scimClientEntityId: entityId } }) → returns scimScope.{FUSIONAUTH_URL}/oauth2/token with the org's scimScope injected. Ignore any inbound scope so Entra and scope-aware IdPs don't conflict.{orgId, clientIdPrefix, status, latencyMs}. Never log secrets or full tokens.Map<clientId, { orgId, scope }> cache with 5-minute TTL keeps steady-state per-request cost to a single outbound FusionAuth call.buildScimCredentials in apps/admin/server/fusionauth-scim.ts:30-49 returns tokenEndpoint = new URL("/api/scim/token", env.AUTH_PUBLIC_URL).toString() instead of the FusionAuth URL.apps/admin/app/settings/sso/_components.tsx:627-640.orgAdminProcedure organizations.issueScimBearerToken (NOT the public shim — internal, audited).getScimClientCredentials to fetch the live secret, runs the client_credentials exchange against FusionAuth with the correct scope, returns { accessToken, expiresIn, expiresAt }.userId, orgId, scimClientEntityId.docs/scim-customer-setup-guide-okta.md:115-122: Okta now uses OAuth 2.0 auth mode (not HTTP Header). Drop the curl section.docs/scim-customer-setup-guide-entra.md: note that the displayed tokenEndpoint changes to our domain. Entra works the same way.AppError.clientId → org at request time via FusionAuth entity search + cache. Trades a per-request FusionAuth lookup (warm cache: free; cold cache: ~50ms) for zero migration and zero backfill. Works immediately for already-provisioned orgs./oauth2/token keeps working; customers move to the new endpoint on their next SCIM (re)configuration. A note will be added to the regenerate confirmation dialog.GET /api/entity/search?queryString=clientId:<value> actually returns the entity. Top-level entity fields are indexed by FusionAuth's Elasticsearch backend, so this should work. Fallback if not: store the clientId in the entity's data field via PATCH /api/entity/{id} once and search data.clientId instead (still no DB schema change).Retry-After.docs/scim-customer-setup-guide-okta.md and docs/scim-customer-setup-guide-entra.md updated to reflect the new flow.