Identity providers

Configuring a generic OIDC provider

The mcp-oauth-inbound policy is the catch-all for OIDC identity providers that don't yet have a first-class wrapper. It accepts the OIDC URLs explicitly and otherwise behaves the same as every per-provider wrapper.

Use this policy when your IdP doesn't appear in the provider catalog. Common cases:

  • Ory Hydra — self-hosted OAuth 2.0/OIDC.
  • Authentik — open-source IdP.
  • ZITADEL — open-source IdP.
  • FusionAuth — self-hosted IdP.
  • PingFederate — enterprise IdP (use this policy, not mcp-ping-oauth-inbound, which is for PingOne cloud).
  • A custom OIDC server you operate yourself.

If your IdP is on the catalog, use the dedicated wrapper instead — it validates provider-specific inputs at boot.

Read the authentication overview first for the two-layer model.

What the gateway needs from your IdP

The gateway needs three pieces of information about your IdP:

  1. The OIDC issuer URL — the value of iss in ID tokens.
  2. The JWKS URL — where the gateway fetches the IdP's public keys to verify ID tokens.
  3. The authorize URL — where the gateway redirects the user's browser to log in.

For the federated authorization-code exchange you also need a token URL, a client ID, and a client secret. The options reference below lists every field.

Most OIDC providers publish all four URLs in a discovery document at {issuer}/.well-known/openid-configuration. Fetch that document in a browser to copy the values.

Set up the OIDC application

Each IdP exposes its application registration differently, but every flow lands at the same place:

  1. Create a new OIDC web application (or "regular web application", "OIDC client", "confidential client" — terminology varies).
  2. Set the redirect URI to https://<gateway-host>/__zuplo/oauth/callback. Add http://localhost:9000/__zuplo/oauth/callback for local development with zuplo dev. If your MCP client uses 127.0.0.1, use that hostname in the redirect URI too. See Local development.
  3. Note the client ID and client secret.
  4. Restrict the application to the users or groups who should be able to authenticate against the gateway.

Wire the policy into the gateway

Add the policy to config/policies.json:

JSONCode
{ "name": "oidc-managed-oauth", "policyType": "mcp-oauth-inbound", "handler": { "module": "$import(@zuplo/runtime/mcp-gateway)", "export": "McpOAuthInboundPolicy", "options": { "oidc": { "issuer": "https://idp.example.com", "jwksUrl": "https://idp.example.com/.well-known/jwks.json" }, "browserLogin": { "url": "https://idp.example.com/oauth2/authorize", "tokenUrl": "https://idp.example.com/oauth2/token", "clientId": "$env(OIDC_CLIENT_ID)", "clientSecret": "$env(OIDC_CLIENT_SECRET)" } } } }

Set OIDC_CLIENT_ID and OIDC_CLIENT_SECRET in your project's environment configuration (the secret goes in the secret store).

Attach the policy to each MCP route in config/routes.oas.json:

JSONCode
{ "paths": { "/mcp/linear-v1": { "get,post": { "operationId": "linear-mcp-server", "x-zuplo-route": { "corsPolicy": "none", "handler": { "module": "$import(@zuplo/runtime/mcp-gateway)", "export": "McpProxyHandler", "options": { "rewritePattern": "https://mcp.linear.app/mcp", }, }, "policies": { "inbound": ["oidc-managed-oauth", "mcp-token-exchange-linear"], }, }, }, }, }, }

Register the gateway plugin in modules/zuplo.runtime.ts:

TypeScriptCode
import { RuntimeExtensions } from "@zuplo/runtime"; import { McpGatewayPlugin } from "@zuplo/runtime/mcp-gateway"; export function runtimeInit(runtime: RuntimeExtensions) { runtime.addPlugin(new McpGatewayPlugin()); }

Attach the same MCP OAuth policy to every MCP route. Attaching different OAuth policies causes a configuration error.

Local development shortcut

To test locally without an identity provider, set browserLogin.url to the dev-login endpoint:

JSONCode
{ "options": { "oidc": { "issuer": "http://127.0.0.1:9000", "jwksUrl": "http://127.0.0.1:9000/jwks" }, "browserLogin": { "url": "http://127.0.0.1:9000/__zuplo/oauth/dev-login" } } }

When browserLogin.url points at /__zuplo/oauth/dev-login, you don't need tokenUrl, clientId, or clientSecret. The endpoint is only served on loopback origins; production deployments cannot reach it.

The policy requires oidc.issuer and oidc.jwksUrl, but dev-login doesn't use them. These values are placeholders; the gateway doesn't serve /jwks. Use 127.0.0.1 in your MCP client URL to match this example.

See the local development guide for the rest of the local setup.

Full options reference

mcp-oauth-inbound has two required option groups: oidc and browserLogin.

OptionRequiredDefaultNotes
oidc.issueryes—The OIDC issuer URL. Must include the scheme.
oidc.jwksUrlyes—JWKS endpoint that publishes the IdP's signing keys.
oidc.audiencenounsetOptional ID-token audience override. Leave unset when ID tokens use the OIDC client_id as their audience.
browserLogin.urlyes—The IdP's /authorize endpoint. The loopback /__zuplo/oauth/dev-login shortcut works for local dev.
browserLogin.tokenUrlfor federated OIDC—The IdP's token endpoint. Required for the federated authorization-code exchange.
browserLogin.clientIdfor federated OIDC—OIDC client_id registered with the IdP.
browserLogin.clientSecretfor federated OIDC—OIDC client_secret. Use $env(...).
browserLogin.scopenoopenid profile emailOIDC scopes requested during browser login.
browserLogin.audiencenounsetOptional audience parameter for Auth0-style API audiences.
browserLogin.remoteTimeoutMsno10000Outbound timeout for IdP calls.
browserLogin.stateTtlSecondsno900Browser-login state record lifetime.
browserLogin.sessionTtlSecondsno28800Browser session cookie lifetime (8 hours).
gateway.accessTokenTtlSecondsno900Gateway-issued access token lifetime.
gateway.refreshTokenTtlSecondsnolong-livedGateway-issued refresh token lifetime.
gateway.cimdEnablednotrueAdvertise CIMD support in AS metadata.

Notes for specific providers

  • Ory Hydra. Discovery lives at {issuer}/.well-known/openid-configuration; set the issuer to the public-facing Hydra URL.
  • Authentik. The issuer is https://<authentik-host>/application/o/<slug>/ (note the trailing slash). The metadata document is at that issuer plus .well-known/openid-configuration.
  • ZITADEL. The issuer is your ZITADEL custom domain; metadata is at {issuer}/.well-known/openid-configuration.
  • FusionAuth. The issuer is your FusionAuth host; metadata is at {issuer}/.well-known/openid-configuration.
  • PingFederate. Use this generic policy (not the PingOne wrapper). PingFederate deployments can customize issuer hosts, issuer paths, and endpoint paths; copy the four URLs from your federation metadata.
  • Google Workspace. Google has a first-class wrapper — Configuring Google.
  • Microsoft Entra ID. Entra has a first-class wrapper — Configuring Microsoft Entra.
  • Keycloak. Keycloak has a first-class wrapper — Configuring Keycloak.

In every case, the gateway only needs the four URL fields (issuer, JWKS, authorize, token) plus a client ID and secret.

Test the configuration

The fastest sanity check is to connect an MCP client:

  1. Open Claude Desktop, Cursor, Claude Code, or another OAuth-aware MCP client.
  2. Add a remote MCP server pointing at one of your /mcp/{slug} routes.
  3. The client should redirect you to your IdP's login page. After login, the gateway's consent screen renders. Approve it.
  4. The client receives an access token and can call tools/list.

If something fails partway through, walk the flow manually using the manual OAuth testing guide — it exercises every endpoint with curl so you can see the raw responses.

Common issues

  • The gateway returns 500 at boot. A required option is missing or invalid. Check the runtime logs for the configuration error.
  • ID token verification fails. The oidc.jwksUrl doesn't match the IdP's actual JWKS endpoint, or the IdP rotated keys. Restart the gateway to clear the JWKS cache.
  • invalid_audience from the gateway's token endpoint. The MCP client is reusing a token bound to a different route. Each gateway-issued token is scoped to one MCP route.
  • MCP client can't discover the AS. Confirm the mcp-oauth-inbound policy is attached to the route in routes.oas.json and the McpGatewayPlugin is registered in modules/zuplo.runtime.ts.
  • Browser login redirects but the callback fails. The https://<gateway-host>/__zuplo/oauth/callback URL isn't on the application's redirect URI allow-list at the IdP.
Last modified on