# Configuring Okta (or any OIDC provider)

The `mcp-oauth-inbound` policy lets the MCP Gateway delegate browser login to
any OIDC-compatible identity provider. Where the
[Auth0 wrapper](./configuring-auth0.mdx) derives all the URLs from a single
domain, the generic policy requires you to provide the OIDC URLs explicitly.

This guide uses **Okta** as the concrete example, but the same pattern applies
to Microsoft Entra ID, Keycloak, Ory Hydra, Authentik, Google Workspace, or any
other IdP that exposes RFC 8414 authorization-server metadata or OpenID Connect
Discovery.

Read the [authentication overview](./overview.mdx) for the two-layer model and
the role each policy plays before starting.

## 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 most IdPs you also need a token URL, a client ID, and a client secret so the
gateway can complete the authorization-code exchange after browser login. The
[options reference](#full-options-reference) lists every field.

## Set up Okta

The MCP Gateway acts as an OAuth 2.1 authorization server in front of Okta. Okta
handles browser login and identity; the gateway issues its own access tokens
bound to MCP routes. The Okta application you create represents the **gateway's
identity** against Okta, not the MCP client.

### Create an OIDC application

1. In the Okta Admin Console, open **Applications > Applications** and click
   **Create App Integration**.
2. Choose **OIDC - OpenID Connect** as the sign-in method.
3. Choose **Web Application** as the application type and click **Next**.
4. Give the integration a name (for example, `Zuplo MCP Gateway`).
5. Under **Grant types**, leave **Authorization Code** checked. Refresh token
   isn't needed by the gateway here because the gateway uses Okta only for
   browser identity, not as a long-running token source.
6. Set **Sign-in redirect URIs** to your gateway's
   `https://<gateway-host>/oauth/callback`. Add
   `http://localhost:9000/oauth/callback` for local development.
7. Click **Save**.

Note the **Client ID** and **Client Secret** from the application's **General**
tab. You'll wire these into the policy in the next section.

### Find the OIDC URLs

From the Okta Admin Console, navigate to **Security > API** and pick the
authorization server you want to use (the **default** one works for most setups;
a custom authorization server gives you more control over scopes and audiences).

The authorization server's metadata page shows an **Issuer URI** and a
**Metadata URI**. The issuer URI is what you pass as `oidc.issuer`. Fetch the
metadata URI in a browser (`{issuer}/.well-known/oauth-authorization-server` or
`{issuer}/.well-known/openid-configuration`) to find:

- `jwks_uri` — pass this as `oidc.jwksUrl`.
- `authorization_endpoint` — pass this as `browserLogin.url`.
- `token_endpoint` — pass this as `browserLogin.tokenUrl`.

For the Okta default authorization server, the URLs look like:

```text
Issuer:        https://your-org.okta.com/oauth2/default
JWKS:          https://your-org.okta.com/oauth2/default/v1/keys
Authorize:     https://your-org.okta.com/oauth2/default/v1/authorize
Token:         https://your-org.okta.com/oauth2/default/v1/token
```

## Wire the policy into the gateway

Add the policy to `config/policies.json`:

```json
{
  "name": "okta-managed-oauth",
  "policyType": "mcp-oauth-inbound",
  "handler": {
    "module": "$import(@zuplo/runtime/mcp-gateway)",
    "export": "McpOAuthInboundPolicy",
    "options": {
      "oidc": {
        "issuer": "https://your-org.okta.com/oauth2/default",
        "jwksUrl": "https://your-org.okta.com/oauth2/default/v1/keys"
      },
      "browserLogin": {
        "url": "https://your-org.okta.com/oauth2/default/v1/authorize",
        "tokenUrl": "https://your-org.okta.com/oauth2/default/v1/token",
        "clientId": "$env(OKTA_CLIENT_ID)",
        "clientSecret": "$env(OKTA_CLIENT_SECRET)"
      }
    }
  }
}
```

Set `OKTA_CLIENT_ID` and `OKTA_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": {
      "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": ["okta-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`.
The complete schema is documented on the policy reference page; the fields
you'll touch most often are:

| 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                 | ~10 years              | Gateway-issued refresh token lifetime.                                                                      |
| `gateway.cimdEnabled`            | no                 | `true`                 | Advertise CIMD support in AS metadata.                                                                      |

## Notes for specific providers

- **Microsoft Entra ID.** The issuer is
  `https://login.microsoftonline.com/{tenant}/v2.0`; the metadata document lives
  at `{issuer}/.well-known/openid-configuration`. Use the v2 endpoints.
- **Keycloak.** The issuer is `https://<keycloak-host>/realms/<realm>`; the
  metadata document lives at `{issuer}/.well-known/openid-configuration`.
- **Ory Hydra.** Discovery lives at `{issuer}/.well-known/openid-configuration`;
  set the issuer to the public-facing Hydra URL.
- **Google Workspace.** Use `https://accounts.google.com` as the issuer;
  metadata is at `https://accounts.google.com/.well-known/openid-configuration`.

In all cases, 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 try connecting 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 on the
   gateway.
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)
- `mcp-oauth-inbound` policy reference
- [Configuring Auth0](./configuring-auth0.mdx)
- [Per-user OAuth to upstream MCP servers](./upstream-oauth.mdx)
