# 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](./overview.mdx#identity-providers). 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](./overview.mdx#identity-providers), use the
dedicated wrapper instead — it validates provider-specific inputs at boot.

Read the [authentication overview](./overview.mdx) 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](#full-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>/oauth/callback`. Add
   `http://localhost:9000/oauth/callback` for local development with
   `zuplo dev`.
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`:

```json
{
  "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`:

```jsonc
{
  "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`:

```ts
import { RuntimeExtensions } from "@zuplo/runtime";
import { McpGatewayPlugin } from "@zuplo/runtime/mcp-gateway";

export function runtimeInit(runtime: RuntimeExtensions) {
  runtime.addPlugin(new McpGatewayPlugin());
}
```

One MCP OAuth policy serves every MCP route in the project. The gateway rejects
projects that declare more than one MCP OAuth policy.

## Local development shortcut

For local development without round-tripping a real IdP, set `browserLogin.url`
to the loopback dev-login endpoint:

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

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

See the [local development guide](../code-config/local-development.mdx) for the
rest of the local setup.

## Full options reference

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

| Option                           | Required           | Default                | Notes                                                                                                       |
| -------------------------------- | ------------------ | ---------------------- | ----------------------------------------------------------------------------------------------------------- |
| `oidc.issuer`                    | yes                | —                      | The OIDC issuer URL. Must include the scheme.                                                               |
| `oidc.jwksUrl`                   | yes                | —                      | JWKS endpoint that publishes the IdP's signing keys.                                                        |
| `oidc.audience`                  | no                 | unset                  | Optional ID-token audience override. Leave unset when ID tokens use the OIDC `client_id` as their audience. |
| `browserLogin.url`               | yes                | —                      | The IdP's `/authorize` endpoint. The loopback `/oauth/dev-login` shortcut works for local dev.              |
| `browserLogin.tokenUrl`          | for federated OIDC | —                      | The IdP's token endpoint. Required for the federated authorization-code exchange.                           |
| `browserLogin.clientId`          | for federated OIDC | —                      | OIDC client_id registered with the IdP.                                                                     |
| `browserLogin.clientSecret`      | for federated OIDC | —                      | OIDC client_secret. Use `$env(...)`.                                                                        |
| `browserLogin.scope`             | no                 | `openid profile email` | OIDC scopes requested during browser login.                                                                 |
| `browserLogin.audience`          | no                 | unset                  | Optional `audience` parameter for Auth0-style API audiences.                                                |
| `browserLogin.remoteTimeoutMs`   | no                 | `10000`                | Outbound timeout for IdP calls.                                                                             |
| `browserLogin.stateTtlSeconds`   | no                 | `900`                  | Browser-login state record lifetime.                                                                        |
| `browserLogin.sessionTtlSeconds` | no                 | `28800`                | Browser session cookie lifetime (8 hours).                                                                  |
| `gateway.accessTokenTtlSeconds`  | no                 | `900`                  | Gateway-issued access token lifetime.                                                                       |
| `gateway.refreshTokenTtlSeconds` | no                 | long-lived             | Gateway-issued refresh token lifetime.                                                                      |
| `gateway.cimdEnabled`            | no                 | `true`                 | Advertise 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](./configuring-google.mdx).
- **Microsoft Entra ID.** Entra has a first-class wrapper —
  [Configuring Microsoft Entra](./configuring-entra.mdx).
- **Keycloak.** Keycloak has a first-class wrapper —
  [Configuring Keycloak](./configuring-keycloak.mdx).

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](./manual-oauth-testing.mdx) — 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>/oauth/callback` URL isn't on the application's
  redirect URI allow-list at the IdP.

## Related

- [Authentication overview](./overview.mdx) — the provider catalog and the
  two-layer OAuth model.
- [Per-user OAuth to upstream MCP servers](./upstream-oauth.mdx)
- [Manual OAuth testing](./manual-oauth-testing.mdx)
