zuplo 7.8.0 → 7.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -48,6 +48,11 @@ This will open the Auth0 configuration window where you can make changes to your
48
48
  connection settings. This is useful if you need to rotate certificates or client
49
49
  secrets or if you need to add or remove domains.
50
50
 
51
+ Client secrets and signing certificates expire, and an expired credential blocks
52
+ sign-in for everyone on the connection. See
53
+ [Renew SSO Credentials](./renew-sso-credentials.mdx) for how to rotate a
54
+ credential, monitor expiry dates, and recover if a credential already expired.
55
+
51
56
  ### Disable Single Sign On
52
57
 
53
58
  If you need to disable SSO completely, contact Zuplo support for assistance.
@@ -0,0 +1,142 @@
1
+ ---
2
+ title: Renew SSO Credentials
3
+ sidebar_label: Renew SSO Credentials
4
+ ---
5
+
6
+ <EnterpriseFeature name="Single Sign On" />
7
+
8
+ Your SSO connection authenticates Zuplo to your identity provider with a
9
+ credential that you create and own:
10
+
11
+ - An OIDC or OAuth2 **client secret**, for connections that use OpenID Connect.
12
+ - A **SAML signing certificate**, for connections that use SAML.
13
+
14
+ Both kinds expire. When one expires, the identity provider stops accepting the
15
+ sign-in request, and everyone who signs in to Zuplo through that connection is
16
+ locked out until you replace the credential.
17
+
18
+ Renewing the credential is your responsibility. Zuplo can't create a credential
19
+ in your identity provider or extend the expiry date of an existing one.
20
+
21
+ ## Nothing reminds you automatically
22
+
23
+ Most identity providers notify you about a narrower set of credentials than
24
+ administrators expect:
25
+
26
+ - **Microsoft Entra ID** sends automatic expiry email for SAML signing
27
+ certificates on enterprise applications. It doesn't send email about client
28
+ secrets on app registrations.
29
+ - **Auth0**, which Zuplo uses to broker enterprise SSO, sends email about an
30
+ expiring SAML certificate on a connection. It doesn't send email about an
31
+ expiring OIDC client secret.
32
+
33
+ If your connection uses a client secret, assume that no one notifies you. Record
34
+ the expiry date when you create the secret and set your own reminder.
35
+
36
+ ## What an expired credential looks like
37
+
38
+ Sign-in fails for every user on the connection, and the failure happens at your
39
+ identity provider before the user returns to Zuplo. With Microsoft Entra ID, the
40
+ error names the app registration:
41
+
42
+ ```
43
+ The provided client secret keys for app '<app-id>' are expired.
44
+ ```
45
+
46
+ Other providers word it differently. Look for `invalid_client` from an OIDC
47
+ provider, or a message about an expired or untrusted certificate from a SAML
48
+ provider.
49
+
50
+ Two signals point at credential expiry rather than a configuration change:
51
+ sign-in worked until a specific date, and the failure affects every user on the
52
+ connection at once.
53
+
54
+ ## Renew a credential before it expires
55
+
56
+ While you can still sign in to Zuplo, rotate the credential yourself:
57
+
58
+ <Stepper>
59
+
60
+ 1. In your identity provider, create a new client secret or signing certificate
61
+ for the application that Zuplo connects to. Keep the old credential active
62
+ until the new one works.
63
+ 1. Open
64
+ [**Account Settings → Security**](https://portal.zuplo.com/+/account/settings/security)
65
+ in the Zuplo Portal and click "Edit Connection" in the "Single Sign On (SSO)"
66
+ section. See [Edit Single Sign On](./enterprise-sso.mdx#edit-single-sign-on)
67
+ for details.
68
+ 1. Enter the new client secret or upload the new certificate, then save the
69
+ connection.
70
+ 1. Sign out and sign back in to Zuplo to confirm the connection works.
71
+ 1. Remove the old credential from your identity provider.
72
+
73
+ </Stepper>
74
+
75
+ ## Recover from a credential that has already expired
76
+
77
+ :::caution
78
+
79
+ If your account requires enterprise SSO, an expired credential locks out every
80
+ user, including administrators. Nobody can reach **Account Settings → Security**
81
+ to fix the connection, so this case isn't self-serve.
82
+
83
+ :::
84
+
85
+ <Stepper>
86
+
87
+ 1. In your identity provider, create a new client secret or signing certificate
88
+ for the application that Zuplo connects to.
89
+ 1. Email [support@zuplo.com](mailto:support@zuplo.com) and tell them that your
90
+ SSO credential expired. Zuplo can issue a self-service SSO link that lets you
91
+ update the connection without signing in to the Zuplo Portal.
92
+ 1. Open the link, enter the new credential, and save the connection. Make sure
93
+ the connection is still enabled.
94
+ 1. Sign in to Zuplo to confirm that the connection works.
95
+ 1. Remove the old credential from your identity provider.
96
+
97
+ </Stepper>
98
+
99
+ ## Stay ahead of the next expiry
100
+
101
+ - **Set a calendar reminder** for 30 days before the expiry date, on a shared
102
+ team calendar rather than one person's. Credential lifetimes outlast staff
103
+ changes.
104
+ - **Choose a shorter lifetime**, such as six or twelve months, instead of the
105
+ longest one your provider offers. Frequent rotation keeps the procedure
106
+ familiar and documented, and a two-year secret usually expires long after
107
+ everyone who set it up has forgotten it exists.
108
+ - **Prefer a certificate credential over a client secret** when your provider
109
+ supports it. Microsoft Entra ID treats certificates as the more secure
110
+ credential type for app registrations.
111
+ - **Rotate with an overlap.** Most providers let you hold two active credentials
112
+ at once, so you can add the new one, switch Zuplo over, verify sign-in, and
113
+ then delete the old one without downtime.
114
+ - **Monitor expiry dates** with the tools your provider gives you. The next
115
+ section links to them.
116
+
117
+ ## Provider documentation
118
+
119
+ Each provider manages and monitors its own credentials. Use their documentation
120
+ for the exact steps.
121
+
122
+ **Microsoft Entra ID**
123
+
124
+ - [Add and manage app credentials](https://learn.microsoft.com/en-us/entra/identity-platform/how-to-add-credentials)
125
+ — add a client secret or certificate to an app registration.
126
+ - [Renew expiring application credentials recommendation](https://learn.microsoft.com/en-us/entra/identity/monitoring-health/recommendation-renew-expiring-application-credential)
127
+ — flags app-registration credentials that expire within the next 30 days.
128
+ Requires a Microsoft Entra Workload ID license.
129
+ - [Export app registrations with expiring secrets and certificates](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/scripts/powershell-export-apps-with-expiring-secrets)
130
+ — a PowerShell sample that reports expiring credentials across your tenant.
131
+
132
+ **Okta**
133
+
134
+ - [Manage secrets and keys for OIDC app client authentication](https://help.okta.com/oie/en-us/content/topics/apps/oauth-client-cred-mgmt.htm)
135
+ - [Manage signing certificates](https://help.okta.com/en-us/content/topics/apps/manage-signing-certificates.htm)
136
+
137
+ **Google Workspace**
138
+
139
+ - [Maintain SAML certificates](https://support.google.com/a/answer/7394709)
140
+
141
+ For any other identity provider, look for the credential management page of the
142
+ application you created for Zuplo.
@@ -68,6 +68,11 @@ On the same **Settings** tab:
68
68
  (plus `http://localhost:9000` for local dev).
69
69
  3. Save changes.
70
70
 
71
+ For local development, use the same hostname in your MCP client URL, **Allowed
72
+ Callback URLs**, and **Allowed Web Origins**. If your client uses `127.0.0.1`,
73
+ replace `localhost` with `127.0.0.1` in both Auth0 settings. See
74
+ [Local development](../code-config/local-development.mdx#use-one-loopback-origin-everywhere).
75
+
71
76
  ### Optional: Set an audience
72
77
 
73
78
  If you want Auth0 to issue identity-bound API access tokens (for example, to
@@ -112,6 +117,9 @@ Set the three environment variables in your Zuplo project's environment
112
117
  configuration. `AUTH0_DOMAIN` is the bare hostname; the secret values belong in
113
118
  the project secret store.
114
119
 
120
+ Use the same variable names in your environment settings and the `$env(...)`
121
+ references in `policies.json`.
122
+
115
123
  Attach the policy to each MCP route in `config/routes.oas.json`:
116
124
 
117
125
  ```jsonc
@@ -55,7 +55,9 @@ at the same place:
55
55
  client", "confidential client" — terminology varies).
56
56
  2. Set the **redirect URI** to `https://<gateway-host>/__zuplo/oauth/callback`.
57
57
  Add `http://localhost:9000/__zuplo/oauth/callback` for local development with
58
- `zuplo dev`.
58
+ `zuplo dev`. If your MCP client uses `127.0.0.1`, use that hostname in the
59
+ redirect URI too. See
60
+ [Local development](../code-config/local-development.mdx#use-one-loopback-origin-everywhere).
59
61
  3. Note the **client ID** and **client secret**.
60
62
  4. Restrict the application to the users or groups who should be able to
61
63
  authenticate against the gateway.
@@ -128,20 +130,20 @@ export function runtimeInit(runtime: RuntimeExtensions) {
128
130
  }
129
131
  ```
130
132
 
131
- One MCP OAuth policy serves every MCP route in the project. The gateway rejects
132
- projects that declare more than one MCP OAuth policy.
133
+ Attach the same MCP OAuth policy to every MCP route. Attaching different OAuth
134
+ policies causes a configuration error.
133
135
 
134
136
  ## Local development shortcut
135
137
 
136
- For local development without round-tripping a real IdP, set `browserLogin.url`
137
- to the loopback dev-login endpoint:
138
+ To test locally without an identity provider, set `browserLogin.url` to the
139
+ dev-login endpoint:
138
140
 
139
141
  ```json
140
142
  {
141
143
  "options": {
142
144
  "oidc": {
143
- "issuer": "http://localhost:9000/",
144
- "jwksUrl": "http://localhost:9000/dev/jwks"
145
+ "issuer": "http://127.0.0.1:9000",
146
+ "jwksUrl": "http://127.0.0.1:9000/jwks"
145
147
  },
146
148
  "browserLogin": {
147
149
  "url": "http://127.0.0.1:9000/__zuplo/oauth/dev-login"
@@ -154,6 +156,10 @@ When `browserLogin.url` points at `/__zuplo/oauth/dev-login`, you don't need
154
156
  `tokenUrl`, `clientId`, or `clientSecret`. The endpoint is only served on
155
157
  loopback origins; production deployments cannot reach it.
156
158
 
159
+ The policy requires `oidc.issuer` and `oidc.jwksUrl`, but dev-login doesn't use
160
+ them. These values are placeholders; the gateway doesn't serve `/jwks`. Use
161
+ `127.0.0.1` in your MCP client URL to match this example.
162
+
157
163
  See the [local development guide](../code-config/local-development.mdx) for the
158
164
  rest of the local setup.
159
165
 
@@ -160,10 +160,10 @@ connect-required error is returned — the proxy forwards transparently.
160
160
 
161
161
  ## Identity providers
162
162
 
163
- The gateway ships a generic OIDC policy plus first-class wrappers for the
164
- identity providers most teams use. You configure exactly one of these policies
165
- per project — the gateway rejects projects that declare more than one MCP OAuth
166
- policy.
163
+ Use a provider-specific policy from the table, or the generic
164
+ `mcp-oauth-inbound` policy for other OIDC providers. Attach the same policy to
165
+ every MCP route in the project. Attaching different MCP OAuth policies causes a
166
+ configuration error.
167
167
 
168
168
  Each wrapper has the same shape: it takes a small set of provider-specific
169
169
  fields (a domain, a tenant ID, a subdomain, and so on) plus `clientId` and
@@ -89,6 +89,18 @@ can request tokens. The `clientRegistration` option controls how:
89
89
  manages OAuth client lifecycle centrally, the upstream provider requires an
90
90
  approved client, or one OAuth client should be shared across multiple routes.
91
91
 
92
+ Manual mode needs a redirect URI registered with the upstream provider. Register
93
+ this URI, where `<connection-id>` is the connection's `id`:
94
+
95
+ ```text
96
+ https://<gateway-host>/__zuplo/auth/connections/<connection-id>/callback
97
+ ```
98
+
99
+ This is different from `/__zuplo/oauth/callback`, which is the callback for the
100
+ identity provider that signs users in to the gateway itself. See
101
+ [Upstream connection endpoints](../reference.mdx#upstream-connection-endpoints)
102
+ in the MCP Gateway reference.
103
+
92
104
  Both modes are first-class. CIMD documents are accessible to the upstream
93
105
  provider over HTTPS — the upstream fetches them as part of its OAuth
94
106
  registration flow. The CIMD URL includes the `authProfileId` query parameter so
@@ -2,61 +2,53 @@
2
2
  title: "Local development"
3
3
  sidebar_label: "Local development"
4
4
  description:
5
- Run the Zuplo MCP Gateway locally with zuplo dev, bypass your identity
6
- provider with the loopback /__zuplo/oauth/dev-login shortcut, wire up an MCP
7
- client against 127.0.0.1, and recover cleanly from the known workerd restart
8
- quirk.
5
+ Run the MCP Gateway locally, test OAuth without an identity provider, and
6
+ configure local callback URLs and environment variables for your MCP clients.
9
7
  ---
10
8
 
11
- The MCP Gateway runs the same way locally as any Zuplo project — `zuplo dev`,
12
- port `9000`, hot reload on file changes. A few details are specific to the
13
- gateway: the gateway prefers `127.0.0.1` over `localhost`, OAuth login can be
14
- short-circuited entirely in dev, and the local `workerd` worker needs a full
15
- restart after some MCP client connect attempts.
9
+ Use `zuplo dev` to run the MCP Gateway locally on port `9000` with hot reload.
10
+ For local testing, you can sign in without setting up an identity provider.
16
11
 
17
12
  ## Start the gateway
18
13
 
19
- From the project root:
14
+ Start the development server from the project root:
20
15
 
21
16
  ```bash
22
17
  zuplo dev
23
18
  ```
24
19
 
25
- The gateway listens at `http://127.0.0.1:9000`. Each MCP route in
26
- `routes.oas.json` becomes reachable at that origin — for example
27
- `http://127.0.0.1:9000/mcp/linear-v1`.
20
+ If the project has the standard `dev` script, `npm run dev` runs the same
21
+ command.
28
22
 
29
- ## Prefer `127.0.0.1` over `localhost`
23
+ The gateway listens on port `9000`. Each MCP route in `routes.oas.json` becomes
24
+ reachable there — for example `http://127.0.0.1:9000/mcp/linear-v1`.
30
25
 
31
- OAuth metadata, callback URLs, and the in-dev login shortcut all key off the
32
- request origin. Other loopback aliases (`localhost`, `::1`, `[::1]`) can cause
33
- subtle OAuth issues in local dev.
26
+ ## Use one loopback origin everywhere
34
27
 
35
- When configuring an MCP client locally, use `127.0.0.1`:
28
+ Use either `http://localhost:9000` or `http://127.0.0.1:9000` consistently for
29
+ local gateway URLs. The gateway treats them as different origins.
36
30
 
37
- ```jsonc
38
- // Good
39
- "url": "http://127.0.0.1:9000/mcp/linear-v1"
31
+ Use the same hostname in these settings:
40
32
 
41
- // Avoid in local dev — works for most things, breaks subtly for OAuth
42
- "url": "http://localhost:9000/mcp/linear-v1"
43
- ```
33
+ - The gateway URL in your MCP client configuration.
34
+ - `browserLogin.url` when using the local dev-login endpoint.
35
+ - The gateway callback and allowed-origin URLs registered with your identity
36
+ provider, when testing with one.
44
37
 
45
- The same applies to any callback or redirect URI you configure with an identity
46
- provider for local testing.
38
+ Mixing hostnames can cause `oauth_callback_mismatch` during local login or a
39
+ callback URL error from your identity provider. The examples on this page use
40
+ `127.0.0.1`.
47
41
 
48
42
  ## Bypass your IdP with `/__zuplo/oauth/dev-login`
49
43
 
50
- Setting up a real OIDC provider for local development is friction — you'd have
51
- to register a localhost callback, manage test users, and so on. The gateway
52
- exposes a loopback-only shortcut that skips the IdP round-trip entirely and
53
- signs you in as a fixed `dev-browser-user` subject.
44
+ The local `/__zuplo/oauth/dev-login` endpoint signs you in as `dev-browser-user`
45
+ without an identity provider.
54
46
 
55
47
  To use it, set `browserLogin.url` to the dev-login URL when configuring the
56
48
  OAuth policy:
57
49
 
58
50
  ```jsonc
59
- // config/policies.json — using the generic mcp-oauth-inbound policy
51
+ // config/policies.json
60
52
  {
61
53
  "name": "dev-oauth",
62
54
  "policyType": "mcp-oauth-inbound",
@@ -64,9 +56,10 @@ OAuth policy:
64
56
  "module": "$import(@zuplo/runtime/mcp-gateway)",
65
57
  "export": "McpOAuthInboundPolicy",
66
58
  "options": {
59
+ // Required placeholders for local dev-login.
67
60
  "oidc": {
68
61
  "issuer": "http://127.0.0.1:9000",
69
- "jwksUrl": "http://127.0.0.1:9000/.well-known/jwks.json",
62
+ "jwksUrl": "http://127.0.0.1:9000/jwks",
70
63
  },
71
64
  "browserLogin": {
72
65
  "url": "http://127.0.0.1:9000/__zuplo/oauth/dev-login",
@@ -81,19 +74,55 @@ When `browserLogin.url` points at `/__zuplo/oauth/dev-login`, the
81
74
  `browserLogin.clientSecret` options aren't required. The consent page renders
82
75
  normally.
83
76
 
77
+ The policy requires `oidc.issuer` and `oidc.jwksUrl`, but dev-login doesn't use
78
+ them. Keep the placeholder values in this example for local testing. The `/jwks`
79
+ URL is a placeholder, not an endpoint the gateway serves.
80
+
84
81
  :::caution
85
82
 
86
- The `/__zuplo/oauth/dev-login` route returns `403 Forbidden` for any request
87
- that doesn't arrive over loopback. It's not a security risk to leave configured
88
- for production, but it's also not useful — production deployments should use a
89
- real OIDC provider through one of the
90
- [IdP-specific wrappers](../auth/overview.mdx#identity-providers).
83
+ Use an OIDC identity provider in deployed environments. The dev-login endpoint
84
+ only accepts requests from loopback origins and returns `403 Forbidden` for
85
+ other origins.
91
86
 
92
87
  :::
93
88
 
94
- A common pattern is keeping two OAuth policies in the project — one for
95
- production (Auth0, Okta, Entra, or any other supported IdP) and one for local
96
- dev — and selecting between them in `routes.oas.json` based on the environment.
89
+ ### Configure local and deployed environments
90
+
91
+ Attach the same MCP OAuth policy to every MCP route. To use dev-login locally
92
+ and an identity provider when deployed, configure a generic `mcp-oauth-inbound`
93
+ policy with environment variables:
94
+
95
+ ```jsonc
96
+ // config/policies.json
97
+ "options": {
98
+ "oidc": {
99
+ "issuer": "$env(OIDC_ISSUER)",
100
+ "jwksUrl": "$env(OIDC_JWKS_URL)",
101
+ },
102
+ "browserLogin": {
103
+ "url": "$env(BROWSER_LOGIN_URL)",
104
+ "tokenUrl": "$env(BROWSER_LOGIN_TOKEN_URL)",
105
+ "clientId": "$env(BROWSER_LOGIN_CLIENT_ID)",
106
+ "clientSecret": "$env(BROWSER_LOGIN_CLIENT_SECRET)",
107
+ },
108
+ },
109
+ ```
110
+
111
+ For local development, set these values:
112
+
113
+ ```bash title=".env"
114
+ OIDC_ISSUER=http://127.0.0.1:9000
115
+ OIDC_JWKS_URL=http://127.0.0.1:9000/jwks
116
+ BROWSER_LOGIN_URL=http://127.0.0.1:9000/__zuplo/oauth/dev-login
117
+ ```
118
+
119
+ Leave `BROWSER_LOGIN_TOKEN_URL`, `BROWSER_LOGIN_CLIENT_ID`, and
120
+ `BROWSER_LOGIN_CLIENT_SECRET` unset locally. In each deployed environment, set
121
+ all six variables to your identity provider's values.
122
+
123
+ Provider-specific policies such as `mcp-auth0-oauth-inbound` use the provider's
124
+ login page and don't support dev-login. See
125
+ [Define one OAuth policy](./overview.mdx#2-define-one-oauth-policy).
97
126
 
98
127
  ## Environment variables
99
128
 
@@ -115,6 +144,9 @@ AUTH0_AUDIENCE=https://mcp-gateway.example.com
115
144
  `.env` is read at `zuplo dev` startup. Restart the dev server after adding or
116
145
  changing an environment variable.
117
146
 
147
+ Match your environment variable names to the `$env(...)` references in
148
+ `policies.json`.
149
+
118
150
  Never commit `.env` to source control. Instead, check in a `.env.example` (or
119
151
  `env.example`) that documents which variables are required and an
120
152
  empty/placeholder value for each.
@@ -61,6 +61,9 @@ supported IdP. The Auth0 case looks like this:
61
61
  }
62
62
  ```
63
63
 
64
+ Match your environment variable names to the `$env(...)` references in
65
+ `policies.json`.
66
+
64
67
  Each wrapper takes a small set of provider-specific options (a domain, a tenant
65
68
  ID, a subdomain, and so on) and derives the OIDC URLs from them. For IdPs
66
69
  without a dedicated wrapper — Ory Hydra, Authentik, FusionAuth, PingFederate, a
@@ -70,10 +73,8 @@ the worked example.
70
73
 
71
74
  :::caution
72
75
 
73
- A project can have only one MCP OAuth policy. The gateway rejects any
74
- configuration with two, regardless of variant. The same policy is attached to
75
- every MCP route in the project — every route authenticates against the same
76
- identity provider.
76
+ Attach the same MCP OAuth policy to every MCP route in the project. Attaching
77
+ different OAuth policies causes a configuration error.
77
78
 
78
79
  :::
79
80
 
@@ -157,9 +158,8 @@ gateway returns a configuration error on the first matching request.
157
158
 
158
159
  ## Verify the gateway is wired up
159
160
 
160
- Start the project with `zuplo dev` and the gateway is reachable at
161
- `http://127.0.0.1:9000/mcp/linear-v1`. A quick sanity check is to send an
162
- unauthenticated POST:
161
+ Start the project with `zuplo dev`, or `npm run dev` if the project's `dev`
162
+ script runs that command. Then send an unauthenticated request to the route:
163
163
 
164
164
  ```bash
165
165
  curl -i -X POST http://127.0.0.1:9000/mcp/linear-v1 \
@@ -176,7 +176,7 @@ skips your IdP during development.
176
176
  ## Add more upstreams
177
177
 
178
178
  The pattern is the same for each additional upstream: one MCP OAuth policy stays
179
- shared across the project, and one `mcp-token-exchange-*` policy and one route
179
+ attached across the project, and one `mcp-token-exchange-*` policy and one route
180
180
  get added per new upstream MCP server. Per-user state is keyed by
181
181
  `(subjectId, upstreamServerId)`, so each user maintains independent connections
182
182
  to each upstream they consent to.
@@ -90,6 +90,20 @@ upstream OAuth provider:
90
90
 
91
91
  Use `$env(...)` for `clientSecret` so the secret stays out of source control.
92
92
 
93
+ When you register the OAuth app with the upstream provider, register this
94
+ redirect URI, where `<connection-id>` is the policy's `id` (for the example
95
+ policy above, `linear`, inferred from the `mcp-token-exchange-linear` name):
96
+
97
+ ```text
98
+ https://<gateway-host>/__zuplo/auth/connections/<connection-id>/callback
99
+ ```
100
+
101
+ This is different from `/__zuplo/oauth/callback`, which is the callback for the
102
+ identity provider that signs users in to the gateway itself. Registering that
103
+ path with the upstream provider fails with `redirect_uri_mismatch`. Both paths
104
+ are listed in the
105
+ [MCP Gateway reference](../reference.mdx#upstream-connection-endpoints).
106
+
93
107
  ## Set scopes when the upstream needs them
94
108
 
95
109
  When the upstream requires specific scopes that aren't discoverable from MCP
@@ -70,10 +70,8 @@ older project and the build complains about the compatibility date, see
70
70
 
71
71
  2. **Add an OAuth policy with the dev-login shortcut**
72
72
 
73
- Setting up a real identity provider for local development is friction. You'd
74
- register a loopback callback, manage test users, and so on. The gateway
75
- exposes a loopback-only shortcut that skips the IdP round-trip entirely and
76
- signs you in as a fixed `dev-browser-user`.
73
+ The local dev-login endpoint signs you in as `dev-browser-user` without an
74
+ identity provider.
77
75
 
78
76
  Open `config/policies.json` and add the generic OAuth policy pointed at the
79
77
  dev-login URL:
@@ -88,7 +86,7 @@ older project and the build complains about the compatibility date, see
88
86
  "options": {
89
87
  "oidc": {
90
88
  "issuer": "http://127.0.0.1:9000",
91
- "jwksUrl": "http://127.0.0.1:9000/.well-known/jwks.json"
89
+ "jwksUrl": "http://127.0.0.1:9000/jwks"
92
90
  },
93
91
  "browserLogin": {
94
92
  "url": "http://127.0.0.1:9000/__zuplo/oauth/dev-login"
@@ -98,32 +96,20 @@ older project and the build complains about the compatibility date, see
98
96
  }
99
97
  ```
100
98
 
99
+ The policy requires `oidc.issuer` and `oidc.jwksUrl`, but dev-login doesn't
100
+ use them. Keep these placeholder values for this tutorial; the gateway
101
+ doesn't serve `/jwks`.
102
+
101
103
  :::caution
102
104
 
103
- `/__zuplo/oauth/dev-login` returns `403 Forbidden` for any request that
104
- doesn't arrive over loopback, so it's safe to leave configured, but only
105
- useful in local dev. Production deployments should use a real OIDC provider
106
- through one of the [IdP wrappers](./auth/overview.mdx#identity-providers). A
107
- common pattern is keeping two OAuth policies (one for production, one for
108
- dev) and selecting between them in `routes.oas.json` by environment.
105
+ Before deploying, configure an OIDC identity provider. The dev-login endpoint
106
+ only accepts requests from loopback origins and returns `403 Forbidden` for
107
+ other origins. See
108
+ [Configure local and deployed environments](./code-config/local-development.mdx#configure-local-and-deployed-environments)
109
+ to use one policy with different environment variables for each environment.
109
110
 
110
111
  :::
111
112
 
112
- When you do switch to a real provider, its policy reads credentials from
113
- `$env(...)` references. Define those values in a `.env` file at the project
114
- root:
115
-
116
- ```bash title=".env"
117
- MCP_AUTH0_DOMAIN=your-tenant.us.auth0.com
118
- MCP_AUTH0_CLIENT_ID=your-auth0-web-app-client-id
119
- MCP_AUTH0_CLIENT_SECRET=your-auth0-web-app-client-secret
120
- ```
121
-
122
- `.env` is read when `npm run dev` starts, so restart the dev server after
123
- adding or changing a variable. Never commit `.env`. Check in a `.env.example`
124
- with placeholder values instead. The dev-login shortcut above needs no
125
- environment variables, so you can skip this until you wire up a provider.
126
-
127
113
  3. **Add a token-exchange policy for the upstream**
128
114
 
129
115
  Each OAuth-protected upstream gets its own `mcp-token-exchange-inbound`
@@ -200,6 +186,8 @@ older project and the build complains about the compatibility date, see
200
186
  npm run dev
201
187
  ```
202
188
 
189
+ The project's `dev` script runs `zuplo dev`.
190
+
203
191
  The route is now reachable at `http://127.0.0.1:9000/mcp/linear-v1`.
204
192
 
205
193
  :::tip{title="Checkpoint: confirm the OAuth policy is wired up"}
@@ -219,12 +207,12 @@ older project and the build complains about the compatibility date, see
219
207
 
220
208
  :::
221
209
 
222
- :::caution{title="Use 127.0.0.1, not localhost"}
210
+ :::caution{title="Use the same hostname for local URLs"}
223
211
 
224
- OAuth metadata and callback URLs key off the request origin. Other loopback
225
- aliases (`localhost`, `::1`) can break OAuth subtly in local dev. See
226
- [Local development](./code-config/local-development.mdx) for the full set of
227
- local-only details, including the known `workerd` restart quirk.
212
+ Use `127.0.0.1` in your MCP client URL to match the policy in this tutorial.
213
+ You can use `localhost` instead, but update both URLs to avoid a callback
214
+ mismatch. See
215
+ [Local development](./code-config/local-development.mdx#use-one-loopback-origin-everywhere).
228
216
 
229
217
  :::
230
218
 
@@ -124,15 +124,12 @@ result on your machine.
124
124
 
125
125
  5. **Configure upstream authentication**
126
126
 
127
- The **Outbound Auth** step controls whether the gateway needs upstream
128
- credentials before forwarding MCP requests. Linear requires OAuth, so choose:
129
- - **User OAuth**: exchange each user's gateway grant for an upstream MCP
130
- grant, so every user connects their own Linear account.
131
- - **Dynamic** OAuth client registration: the gateway registers itself with
132
- Linear's OAuth server on demand, so no upstream client credentials are
133
- needed.
127
+ On **Outbound Auth**, leave **User OAuth** selected so each user connects
128
+ their own Linear account. Click **Next**.
134
129
 
135
- Both are the defaults. Click **Finish**.
130
+ Leave **OAuth client registration** set to **Dynamic**. The gateway registers
131
+ with Linear automatically, so you don't need a Linear client ID or secret.
132
+ Click **Finish**.
136
133
 
137
134
  <ModalScreenshot size="md">
138
135
 
@@ -153,29 +150,32 @@ result on your machine.
153
150
 
154
151
  6. **Set your identity provider's environment variables**
155
152
 
156
- The scaffolded inbound auth policy reads your provider's credentials from
157
- environment variables, so set those before testing. Open your project's
158
- **Settings** from the navigation bar, then click **Environment Variables**
159
- under Project Settings.
153
+ In `config/policies.json`, set the Auth0 policy's environment variable
154
+ references to match these names, then save the file:
155
+
156
+ ```json title="Auth0 policy options"
157
+ {
158
+ "auth0Domain": "$env(AUTH0_DOMAIN)",
159
+ "clientId": "$env(AUTH0_CLIENT_ID)",
160
+ "clientSecret": "$env(AUTH0_CLIENT_SECRET)"
161
+ }
162
+ ```
163
+
164
+ Open your project's **Settings** from the navigation bar, then click
165
+ **Environment Variables** under Project Settings.
160
166
 
161
167
  Add one variable per credential. For the Auth0 example:
162
168
 
163
- | Name | Value | Secret? |
164
- | ------------------------- | -------------------------- | ------- |
165
- | `MCP_AUTH0_DOMAIN` | `your-tenant.us.auth0.com` | No |
166
- | `MCP_AUTH0_CLIENT_ID` | your application client ID | Yes |
167
- | `MCP_AUTH0_CLIENT_SECRET` | your application secret | Yes |
169
+ | Name | Value | Secret? |
170
+ | --------------------- | -------------------------- | ------- |
171
+ | `AUTH0_DOMAIN` | `your-tenant.us.auth0.com` | No |
172
+ | `AUTH0_CLIENT_ID` | your application client ID | Yes |
173
+ | `AUTH0_CLIENT_SECRET` | your application secret | Yes |
168
174
 
169
175
  Check the **Secret** box for the client ID and secret so their values are
170
- hidden in the encrypted secret store. `MCP_AUTH0_DOMAIN` isn't sensitive, so
176
+ hidden in the encrypted secret store. `AUTH0_DOMAIN` isn't sensitive, so
171
177
  leave it unchecked. Click **Save** for each.
172
178
 
173
- <ModalScreenshot size="md">
174
-
175
- ![The three MCP_AUTH0 environment variables in project settings](../../public/media/mcp-gateway-quickstart/06-env-vars.png)
176
-
177
- </ModalScreenshot>
178
-
179
179
  :::note
180
180
 
181
181
  A new deployment is needed for environment variable changes to take effect.
@@ -184,7 +184,7 @@ result on your machine.
184
184
 
185
185
  :::caution
186
186
 
187
- `MCP_AUTH0_DOMAIN` is a bare hostname (`my-tenant.us.auth0.com`), not a URL.
187
+ `AUTH0_DOMAIN` is a bare hostname (`my-tenant.us.auth0.com`), not a URL.
188
188
 
189
189
  :::
190
190
 
@@ -108,51 +108,73 @@ as Entra tenant admin consent. If connections start requiring frequent
108
108
  reauthorization after setting `prompt: false`, remove the option so the consent
109
109
  grant is requested again.
110
110
 
111
- ## Authorization Server Issuer Mismatch
111
+ ## OAuth Metadata Validation
112
112
 
113
- Some providers publish inconsistent OAuth metadata: the protected-resource
114
- document advertises a path-scoped authorization server, but that server's own
115
- metadata reports a different (usually root-origin) `issuer`. ServiceNow is one —
116
- its protected-resource metadata names
117
- `https://<instance>.service-now.com/sncapps/...` while the metadata served there
118
- reports `https://<instance>.service-now.com`. Atlassian and Okta
119
- org-versus-custom authorization servers publish the same shape.
113
+ The gateway checks OAuth metadata before connecting to an upstream. If a
114
+ provider publishes metadata that does not match its URLs, you can skip either of
115
+ the checks below for that connection.
120
116
 
121
- RFC 8414 section 3.3 requires a client to reject a metadata document whose
122
- `issuer` differs from the identifier the well-known URL was built from, so the
123
- gateway hard-fails discovery against these providers. Set
124
- `skipIssuerMetadataValidation` to `true` on that connection to proceed anyway:
117
+ Both options default to `false` and support `user-oauth` and `shared-oauth`.
118
+ They are not available with `id-jag`. Leave them disabled unless you have
119
+ confirmed the mismatch with the provider and trust its metadata and endpoints.
120
+ Enable only the option needed for that mismatch.
121
+
122
+ ### Authorization Server Issuer Mismatch
123
+
124
+ `skipIssuerMetadataValidation` allows the authorization server's metadata to
125
+ report an `issuer` that differs from the server URL used to discover it.
126
+
127
+ For example, the upstream might advertise `https://auth.example.com/team` as its
128
+ authorization server, while that server's metadata reports
129
+ `https://auth.example.com` as its `issuer`. By default, the gateway rejects this
130
+ mismatch. To allow it, set the following policy options:
125
131
 
126
132
  ```json
127
133
  {
128
- "name": "mcp-token-exchange-servicenow",
129
- "policyType": "mcp-token-exchange-inbound",
130
- "handler": {
131
- "module": "$import(@zuplo/runtime/mcp-gateway)",
132
- "export": "McpTokenExchangeInboundPolicy",
133
- "options": {
134
- "displayName": "ServiceNow",
135
- "authMode": "user-oauth",
136
- "skipIssuerMetadataValidation": true
137
- }
138
- }
134
+ "displayName": "Acme",
135
+ "authMode": "user-oauth",
136
+ "skipIssuerMetadataValidation": true
137
+ }
138
+ ```
139
+
140
+ This disables the issuer check required by RFC 8414 section 3.3. The gateway
141
+ accepts the metadata's OAuth endpoints without checking that the document's
142
+ issuer matches the expected authorization server. Only use it after verifying
143
+ that the metadata and endpoints belong to the provider you intend to use.
144
+ Authorization-response issuer validation and the check that the callback uses
145
+ the same authorization server remain enabled.
146
+
147
+ ### Resource Indicator Mismatch
148
+
149
+ `skipResourceIndicatorValidation` allows the `resource` in the upstream's
150
+ protected-resource metadata to differ from the upstream URL configured in the
151
+ route handler's `rewritePattern`.
152
+
153
+ By default, the resource URL must have the same origin (scheme, host, and port)
154
+ as the upstream URL. Its path must also match the upstream path or be a parent
155
+ of it. For example, `https://api.example.com` is valid for an upstream at
156
+ `https://api.example.com/mcp`, but `https://api.example.com/other` is not.
157
+
158
+ A provider might expose its MCP server at `https://mcp.example.com/mcp` but
159
+ advertise `https://api.example.com/mcp` as its resource. If the provider
160
+ confirms that both URLs identify the same service, set the following policy
161
+ options:
162
+
163
+ ```json
164
+ {
165
+ "displayName": "Acme",
166
+ "authMode": "user-oauth",
167
+ "skipResourceIndicatorValidation": true
139
168
  }
140
169
  ```
141
170
 
142
- **Warning**: this puts the connection out of spec. The issuer check is an
143
- unqualified MUST in both RFC 8414 section 3.3 and the MCP authorization spec,
144
- whose own example is the attack it prevents — a document served from
145
- `https://attacker.example/.well-known/oauth-authorization-server` claiming
146
- `"issuer": "https://honest.example"` must be rejected. The check binds the
147
- metadata to the TLS-authenticated host it came from, and with it off the gateway
148
- trusts the authorization, token and JWKS endpoints from a document that never
149
- proved it speaks for that issuer.
150
-
151
- Only enable it for a specific upstream known to publish a mismatched issuer, and
152
- only when the metadata URL's host is itself trusted. Never enable it as a
153
- default. It relaxes the discovery-time check only: the RFC 9207
154
- authorization-response check and the callback-leg authorization-server binding
155
- still apply.
171
+ The gateway still sends the advertised `resource` on OAuth authorization and
172
+ token requests. It skips only the comparison with the configured upstream URL.
173
+ If the advertised resource identifies another service, the gateway could receive
174
+ a token intended for that service and forward it to this upstream. Only enable
175
+ this option when you trust the upstream to receive the resulting token. Issuer
176
+ metadata validation remains enabled unless you also set
177
+ `skipIssuerMetadataValidation`.
156
178
 
157
179
  ## ID-JAG Token Exchange (Cross-App Access)
158
180
 
@@ -174,8 +196,9 @@ Use this mode in enterprise deployments where the gateway and the upstream trust
174
196
  the same identity provider (for example, Okta Cross-App Access), so upstream
175
197
  access follows the caller's identity without a per-user upstream consent flow.
176
198
  The `user-oauth`/`shared-oauth` options — `scopes`, `scopeDelimiter`, `prompt`,
177
- `skipIssuerMetadataValidation`, and `clientRegistration` — do not apply; the
178
- `id-jag` mode is configured entirely through the `idJag` option.
199
+ `skipIssuerMetadataValidation`, `skipResourceIndicatorValidation`, and
200
+ `clientRegistration` — do not apply; the `id-jag` mode is configured entirely
201
+ through the `idJag` option.
179
202
 
180
203
  ```json
181
204
  {
@@ -59,6 +59,9 @@
59
59
  {
60
60
  "required": ["skipIssuerMetadataValidation"]
61
61
  },
62
+ {
63
+ "required": ["skipResourceIndicatorValidation"]
64
+ },
62
65
  {
63
66
  "required": ["clientRegistration"]
64
67
  }
@@ -126,7 +129,12 @@
126
129
  "skipIssuerMetadataValidation": {
127
130
  "type": "boolean",
128
131
  "x-advanced": true,
129
- "description": "Skips the RFC 8414 section 3.3 issuer-echo check when discovering the upstream authorization server. That check requires the `issuer` in the authorization server's metadata to equal the `authorization_servers` entry the protected-resource metadata advertised. Some providers publish a path-scoped authorization server whose metadata reports the root origin instead — for example ServiceNow advertises `https://<instance>.service-now.com/sncapps/...` but its metadata reports `https://<instance>.service-now.com` — so discovery hard-fails without this option. Warning: enabling this puts the connection out of spec. The check is an unqualified MUST in both RFC 8414 section 3.3 and the MCP authorization spec, which require rejecting a metadata document whose `issuer` differs from the identifier the well-known URL was built from; the spec's own example is a document served from `https://attacker.example/.well-known/oauth-authorization-server` claiming `\"issuer\": \"https://honest.example\"`. The echo check binds the metadata to the TLS-authenticated host it came from, and with it off the gateway trusts the authorization, token and JWKS endpoints from a document that never proved it speaks for that issuer. Only enable it for a specific upstream known to publish a mismatched issuer, and only when the metadata URL's host is itself trusted. It relaxes the discovery-time check only — the RFC 9207 authorization-response check and the callback-leg authorization-server binding still apply."
132
+ "description": "Allows the authorization server metadata's `issuer` to differ from the server URL used for discovery. Defaults to `false`. Enable only for a confirmed provider metadata mismatch after verifying that its metadata and OAuth endpoints are trusted. This disables the RFC 8414 section 3.3 issuer check; authorization-response and callback checks still apply. Available only with `user-oauth` and `shared-oauth`."
133
+ },
134
+ "skipResourceIndicatorValidation": {
135
+ "type": "boolean",
136
+ "x-advanced": true,
137
+ "description": "Allows the upstream metadata's `resource` URL to have a different origin or path from the configured upstream URL. Defaults to `false`. The gateway still sends the advertised `resource` on OAuth requests. Enable only for a confirmed provider metadata mismatch: a token intended for another service could otherwise be sent to this upstream. Available only with `user-oauth` and `shared-oauth`."
130
138
  },
131
139
  "clientRegistration": {
132
140
  "x-advanced": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zuplo",
3
- "version": "7.8.0",
3
+ "version": "7.8.1",
4
4
  "type": "module",
5
5
  "description": "The official Zuplo CLI for local development and platform management",
6
6
  "homepage": "https://zuplo.com/docs/cli/overview",
@@ -32,9 +32,9 @@
32
32
  "zuplo": "zuplo.js"
33
33
  },
34
34
  "dependencies": {
35
- "@zuplo/cli": "7.8.0",
36
- "@zuplo/core": "7.8.0",
37
- "@zuplo/runtime": "7.8.0",
38
- "@zuplo/test": "7.8.0"
35
+ "@zuplo/cli": "7.8.1",
36
+ "@zuplo/core": "7.8.1",
37
+ "@zuplo/runtime": "7.8.1",
38
+ "@zuplo/test": "7.8.1"
39
39
  }
40
40
  }