zuplo 6.71.10 → 6.71.12
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.
- package/docs/articles/monetization/monetization-policy.md +42 -23
- package/docs/articles/monetization/programmatic-monetization.md +29 -0
- package/docs/articles/securing-the-gateway-with-client-mtls.mdx +102 -5
- package/docs/dev-portal/zudoku/configuration/navigation.mdx +32 -4
- package/docs/guides/transform-route-params-url-rewrite.mdx +307 -0
- package/docs/mcp-gateway/cross-app-access/overview.mdx +204 -0
- package/docs/mcp-gateway/cross-app-access/policy-reference.mdx +161 -0
- package/docs/mcp-gateway/cross-app-access/quickstart.mdx +330 -0
- package/package.json +4 -4
|
@@ -61,13 +61,14 @@ reads the API key from the `Authorization` header, validates it, and sets
|
|
|
61
61
|
|
|
62
62
|
## Configuration options
|
|
63
63
|
|
|
64
|
-
| Option
|
|
65
|
-
|
|
|
66
|
-
| `meters`
|
|
67
|
-
| `
|
|
68
|
-
| `
|
|
69
|
-
| `
|
|
70
|
-
| `
|
|
64
|
+
| Option | Type | Default | Description |
|
|
65
|
+
| ---------------------- | ------------------ | ----------------- | ----------------------------------------------------- |
|
|
66
|
+
| `meters` | object | _(none)_ | Map of meter keys to increment values |
|
|
67
|
+
| `requiredEntitlements` | string[] | _(none)_ | Entitlement keys the subscription must have access to |
|
|
68
|
+
| `meterOnStatusCodes` | string or number[] | `"200-299"` | Status code range to meter |
|
|
69
|
+
| `authHeader` | string | `"authorization"` | Header to read the API key from |
|
|
70
|
+
| `authScheme` | string | `"Bearer"` | Expected auth scheme prefix |
|
|
71
|
+
| `cacheTtlSeconds` | number | `60` | How long to cache subscription data (minimum 60s) |
|
|
71
72
|
|
|
72
73
|
### `meters`
|
|
73
74
|
|
|
@@ -91,6 +92,23 @@ see the [Dynamic Metering](./dynamic-metering.md) guide.
|
|
|
91
92
|
{ "meters": { "api_credits": 10 } }
|
|
92
93
|
```
|
|
93
94
|
|
|
95
|
+
### `requiredEntitlements`
|
|
96
|
+
|
|
97
|
+
A list of [entitlement](./features.mdx) keys the caller's subscription must have
|
|
98
|
+
access to before the request is allowed. Use it to gate a route on a feature —
|
|
99
|
+
for example, restrict it to plans that include `custom_domains` — without
|
|
100
|
+
writing code.
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{ "requiredEntitlements": ["custom_domains"] }
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The request is allowed only when **every** listed entitlement is present with
|
|
107
|
+
`hasAccess` set to `true`. If any is missing, disabled, or has exhausted its
|
|
108
|
+
quota, the policy returns `403 Forbidden` before the request reaches your
|
|
109
|
+
backend. For access checks that need runtime logic, gate in code instead — see
|
|
110
|
+
[Programmatic Monetization](./programmatic-monetization.md).
|
|
111
|
+
|
|
94
112
|
### `meterOnStatusCodes`
|
|
95
113
|
|
|
96
114
|
Controls which responses count toward metering. By default, only successful
|
|
@@ -256,22 +274,23 @@ the RFC 7807 Problem Details format:
|
|
|
256
274
|
|
|
257
275
|
Common error details:
|
|
258
276
|
|
|
259
|
-
| Condition | `detail` message
|
|
260
|
-
| -------------------------------- |
|
|
261
|
-
| No auth header | `"No Authorization Header"`
|
|
262
|
-
| Wrong auth scheme | `"Invalid Authorization Scheme"`
|
|
263
|
-
| Empty key after the auth scheme | `"No key present"`
|
|
264
|
-
| Cached invalid key or 401 | `"Authorization Failed"`
|
|
265
|
-
| Invalid API key | `"API Key is invalid or does not have access to the API"`
|
|
266
|
-
| Expired API key | `"API Key has expired."`
|
|
267
|
-
| Expired subscription | `"API Key has an expired subscription."`
|
|
268
|
-
| Subscription has no payment | `"Subscription payment status is not available."`
|
|
269
|
-
| Payment not made | `"Payment has not been made."`
|
|
270
|
-
| Payment overdue | `"Payment is overdue. Please update your payment method."`
|
|
271
|
-
| Subscription has no entitlements | `"Subscription entitlements are not available."`
|
|
272
|
-
| Meter not in subscription | `"API Key does not have \"X\" meter provided by the subscription."`
|
|
273
|
-
| Quota exhausted | `"API Key has exceeded the allowed limit for \"X\" meter."`
|
|
274
|
-
| Meter access denied | `"API Key does not have access to \"X\" meter."`
|
|
277
|
+
| Condition | `detail` message |
|
|
278
|
+
| -------------------------------- | ---------------------------------------------------------------------------------- |
|
|
279
|
+
| No auth header | `"No Authorization Header"` |
|
|
280
|
+
| Wrong auth scheme | `"Invalid Authorization Scheme"` |
|
|
281
|
+
| Empty key after the auth scheme | `"No key present"` |
|
|
282
|
+
| Cached invalid key or 401 | `"Authorization Failed"` |
|
|
283
|
+
| Invalid API key | `"API Key is invalid or does not have access to the API"` |
|
|
284
|
+
| Expired API key | `"API Key has expired."` |
|
|
285
|
+
| Expired subscription | `"API Key has an expired subscription."` |
|
|
286
|
+
| Subscription has no payment | `"Subscription payment status is not available."` |
|
|
287
|
+
| Payment not made | `"Payment has not been made."` |
|
|
288
|
+
| Payment overdue | `"Payment is overdue. Please update your payment method."` |
|
|
289
|
+
| Subscription has no entitlements | `"Subscription entitlements are not available."` |
|
|
290
|
+
| Meter not in subscription | `"API Key does not have \"X\" meter provided by the subscription."` |
|
|
291
|
+
| Quota exhausted | `"API Key has exceeded the allowed limit for \"X\" meter."` |
|
|
292
|
+
| Meter access denied | `"API Key does not have access to \"X\" meter."` |
|
|
293
|
+
| Required entitlement missing | `"The required \"X\" entitlement is not allowed or its quota has been exhausted."` |
|
|
275
294
|
|
|
276
295
|
## Pipeline ordering
|
|
277
296
|
|
|
@@ -74,6 +74,35 @@ if (!advancedSearch?.hasAccess) {
|
|
|
74
74
|
}
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
+
:::tip
|
|
78
|
+
|
|
79
|
+
If you only need to check that a feature's entitlement has access — with no
|
|
80
|
+
other runtime logic — skip the custom code and use the policy's
|
|
81
|
+
[`requiredEntitlements`](./monetization-policy.md#requiredentitlements) option
|
|
82
|
+
instead. It rejects the request with `403 Forbidden` when any listed entitlement
|
|
83
|
+
is missing or out of quota:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
// config/policies.json
|
|
87
|
+
{
|
|
88
|
+
"name": "monetization-inbound",
|
|
89
|
+
"policyType": "monetization-inbound",
|
|
90
|
+
"handler": {
|
|
91
|
+
"export": "MonetizationInboundPolicy",
|
|
92
|
+
"module": "$import(@zuplo/runtime)",
|
|
93
|
+
"options": {
|
|
94
|
+
"meters": { "api_requests": 1 },
|
|
95
|
+
"requiredEntitlements": ["advanced_search"]
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Reach for the code path below when the decision needs more than a presence check
|
|
102
|
+
— inspecting the plan key, reading the request, or combining conditions.
|
|
103
|
+
|
|
104
|
+
:::
|
|
105
|
+
|
|
77
106
|
Register the policy and apply it after `monetization-inbound` on the routes you
|
|
78
107
|
want to protect:
|
|
79
108
|
|
|
@@ -88,12 +88,26 @@ zuplo ca-certificate list --account your-account
|
|
|
88
88
|
See the [`ca-certificate` CLI reference](../cli/ca-certificate-create.mdx) for
|
|
89
89
|
all available subcommands (`create`, `list`, `describe`, `update`, `delete`).
|
|
90
90
|
|
|
91
|
-
:::
|
|
91
|
+
:::caution{title="Upload the self-signed root CA, not an intermediate"}
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
Zuplo must build a complete chain from the presented client certificate up to a
|
|
94
|
+
trust anchor. Upload the **self-signed root** CA that anchors the chain — not an
|
|
95
|
+
intermediate or subordinate CA. Uploading a subordinate CA is the most common
|
|
96
|
+
cause of the `FAILED to get issuer certificate` error (see
|
|
97
|
+
[Troubleshooting](#failed-to-get-issuer-certificate)).
|
|
98
|
+
|
|
99
|
+
To confirm a certificate is a self-signed root, check that its subject and
|
|
100
|
+
issuer are identical:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
openssl x509 -in ca.pem -noout -subject -issuer -nameopt RFC2253
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
If `subject` and `issuer` match, it's a root. If they differ, the file is an
|
|
107
|
+
intermediate CA — trace the chain to the root and upload that instead. When your
|
|
108
|
+
client certificates are issued by an intermediate CA, clients still send the
|
|
109
|
+
leaf certificate plus any intermediates when they connect (see
|
|
110
|
+
[Test with curl](#4-test-with-curl)).
|
|
97
111
|
|
|
98
112
|
:::
|
|
99
113
|
|
|
@@ -315,11 +329,73 @@ the policy in a working-copy or preview environment.
|
|
|
315
329
|
`context.incomingRequestProperties.clientMtlsVerificationReason` to see why
|
|
316
330
|
verification failed.
|
|
317
331
|
|
|
332
|
+
### `FAILED to get issuer certificate`
|
|
333
|
+
|
|
334
|
+
This error means Zuplo can't build a complete chain from the presented client
|
|
335
|
+
certificate up to a trusted root. The usual cause is uploading an **intermediate
|
|
336
|
+
or subordinate CA** instead of the **self-signed root** CA that anchors the
|
|
337
|
+
chain.
|
|
338
|
+
|
|
339
|
+
Confirm what you uploaded. A self-signed root has an identical subject and
|
|
340
|
+
issuer:
|
|
341
|
+
|
|
342
|
+
```bash
|
|
343
|
+
openssl x509 -in ca.pem -noout -subject -issuer -nameopt RFC2253
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
If `subject` and `issuer` differ, the file is an intermediate CA. Find the root
|
|
347
|
+
that anchors the chain and re-upload it:
|
|
348
|
+
|
|
349
|
+
```bash
|
|
350
|
+
# Remove the incorrect CA
|
|
351
|
+
zuplo ca-certificate delete --cert-id mtlsca_abc123 --account your-account
|
|
352
|
+
|
|
353
|
+
# Upload the self-signed root instead
|
|
354
|
+
zuplo ca-certificate create --name my_ca --cert ./root-ca.pem --account your-account
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
If your CA is an Active Directory Certificate Services (AD CS) deployment,
|
|
358
|
+
export the issuing CA's own certificate and inspect it:
|
|
359
|
+
|
|
360
|
+
```bash
|
|
361
|
+
# Export the CA certificate (often DER-encoded)
|
|
362
|
+
certutil -ca.cert ca.cer
|
|
363
|
+
|
|
364
|
+
# Convert DER to the PEM format Zuplo requires
|
|
365
|
+
openssl x509 -inform der -in ca.cer -out ca.pem
|
|
366
|
+
|
|
367
|
+
# Verify subject == issuer (a root); if not, export the root above it instead
|
|
368
|
+
openssl x509 -in ca.pem -noout -subject -issuer -nameopt RFC2253
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### `client certificate metadata not provided`
|
|
372
|
+
|
|
373
|
+
The certificate verified at the edge, but the parsed certificate wasn't
|
|
374
|
+
forwarded to your gateway workers, so `request.user.data.mtlsAuth` is empty even
|
|
375
|
+
though the request was authenticated.
|
|
376
|
+
|
|
377
|
+
The most common cause is an **oversized leaf client certificate**. Zuplo's edge
|
|
378
|
+
can only forward client certificates up to roughly 10 KB of DER-encoded data.
|
|
379
|
+
Certificates with large RSA keys, many Subject Alternative Names, or large
|
|
380
|
+
custom extensions can exceed this. Check the DER size of the leaf certificate:
|
|
381
|
+
|
|
382
|
+
```bash
|
|
383
|
+
openssl x509 -in client.pem -outform der | wc -c
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
If the result is near or above ~10,000 bytes, reissue a smaller leaf
|
|
387
|
+
certificate. Trim unnecessary extensions and SANs, or switch to ECDSA keys
|
|
388
|
+
instead of large (4096-bit) RSA keys. This limit applies to the **leaf**
|
|
389
|
+
certificate the edge forwards, not the full chain.
|
|
390
|
+
|
|
318
391
|
### `request.user.data.mtlsAuth` is missing
|
|
319
392
|
|
|
320
393
|
- The policy only attaches metadata when a parseable client certificate is
|
|
321
394
|
present on the request. Confirm the client is sending one.
|
|
322
395
|
- Verify the route includes the `mtls-auth-inbound` policy.
|
|
396
|
+
- If the certificate verifies but metadata is still missing, check the leaf
|
|
397
|
+
certificate size (see
|
|
398
|
+
[`client certificate metadata not provided`](#client-certificate-metadata-not-provided)).
|
|
323
399
|
|
|
324
400
|
### Custom domains
|
|
325
401
|
|
|
@@ -331,6 +407,27 @@ that custom domain.
|
|
|
331
407
|
If you add a custom domain later and your clients aren't being verified against
|
|
332
408
|
it, contact [support@zuplo.com](mailto:support@zuplo.com).
|
|
333
409
|
|
|
410
|
+
### Custom domains behind your own CDN
|
|
411
|
+
|
|
412
|
+
Inbound mTLS requires the TLS handshake to terminate at Zuplo's edge, because
|
|
413
|
+
that's where the client certificate is verified and parsed. If you front your
|
|
414
|
+
Zuplo gateway with **your own CDN** (for example, your own Cloudflare zone) that
|
|
415
|
+
terminates TLS before traffic reaches Zuplo, the handshake — and the client
|
|
416
|
+
certificate — ends at your CDN. Zuplo never sees the certificate, so the
|
|
417
|
+
`mtls-auth-inbound` policy has nothing to verify.
|
|
418
|
+
|
|
419
|
+
You have two supported options:
|
|
420
|
+
|
|
421
|
+
- **Let Zuplo terminate TLS.** Point clients at a Zuplo-managed gateway domain,
|
|
422
|
+
or configure your custom domain directly on Zuplo so Zuplo terminates TLS. The
|
|
423
|
+
client certificate then reaches Zuplo's edge and inbound mTLS works as
|
|
424
|
+
documented above.
|
|
425
|
+
- **Verify mTLS at your CDN.** If you must keep your own CDN in front, terminate
|
|
426
|
+
and verify the client certificate at the CDN, then forward the verified
|
|
427
|
+
identity to Zuplo in a request header. Validate that header in a Zuplo policy
|
|
428
|
+
instead of relying on `mtls-auth-inbound`. Make sure the header can't be
|
|
429
|
+
spoofed by clients that bypass your CDN.
|
|
430
|
+
|
|
334
431
|
## Additional resources
|
|
335
432
|
|
|
336
433
|
- [`mtls-auth-inbound` policy reference](../policies/mtls-auth-inbound.mdx)
|
|
@@ -143,7 +143,10 @@ type NavigationCategory = {
|
|
|
143
143
|
collapsible?: boolean;
|
|
144
144
|
collapsed?: boolean;
|
|
145
145
|
stack?: boolean; // open the category's items as a stacked sub-nav
|
|
146
|
-
link?:
|
|
146
|
+
link?:
|
|
147
|
+
| string
|
|
148
|
+
| { type: "doc"; file: string; label?: string; path?: string }
|
|
149
|
+
| { type: "link"; to: string; label?: string };
|
|
147
150
|
display?:
|
|
148
151
|
| "auth"
|
|
149
152
|
| "anon"
|
|
@@ -158,9 +161,11 @@ type NavigationCategory = {
|
|
|
158
161
|
#### Category links
|
|
159
162
|
|
|
160
163
|
A category can have a `link` property that makes the category label itself clickable, navigating to
|
|
161
|
-
a document. This is useful when you want a category that acts as both a
|
|
164
|
+
a document, a path, or an external URL. This is useful when you want a category that acts as both a
|
|
165
|
+
group and a landing page.
|
|
162
166
|
|
|
163
|
-
The `link` can be a simple string pointing to a file path,
|
|
167
|
+
The `link` can be a simple string pointing to a file path, a `doc` object for more control, or a
|
|
168
|
+
`link` object that points the category label at an arbitrary path or external URL:
|
|
164
169
|
|
|
165
170
|
```tsx title="String shorthand"
|
|
166
171
|
{
|
|
@@ -190,7 +195,7 @@ The `link` can be a simple string pointing to a file path, or an object for more
|
|
|
190
195
|
}
|
|
191
196
|
```
|
|
192
197
|
|
|
193
|
-
The object form supports these properties:
|
|
198
|
+
The `doc` object form supports these properties:
|
|
194
199
|
|
|
195
200
|
| Property | Type | Description |
|
|
196
201
|
| -------- | -------- | -------------------------------------------------------- |
|
|
@@ -199,6 +204,29 @@ The object form supports these properties:
|
|
|
199
204
|
| `label` | `string` | Override the label (defaults to the document title) |
|
|
200
205
|
| `path` | `string` | Custom URL path (overrides the default file-based route) |
|
|
201
206
|
|
|
207
|
+
```tsx title="Link form pointing to a path or external URL"
|
|
208
|
+
{
|
|
209
|
+
type: "category",
|
|
210
|
+
label: "API Reference",
|
|
211
|
+
link: {
|
|
212
|
+
type: "link",
|
|
213
|
+
to: "/api",
|
|
214
|
+
},
|
|
215
|
+
items: [
|
|
216
|
+
"guides/authentication",
|
|
217
|
+
"guides/rate-limits",
|
|
218
|
+
],
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The `link` object form supports these properties:
|
|
223
|
+
|
|
224
|
+
| Property | Type | Description |
|
|
225
|
+
| -------- | -------- | ----------------------------------- |
|
|
226
|
+
| `type` | `"link"` | Must be `"link"` |
|
|
227
|
+
| `to` | `string` | Path or external URL to navigate to |
|
|
228
|
+
| `label` | `string` | Override the category label |
|
|
229
|
+
|
|
202
230
|
### `type: doc`
|
|
203
231
|
|
|
204
232
|
Doc is used to reference markdown files. The `label` is the text that will be displayed, and the
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Transform Route Parameters for URL Rewrite
|
|
3
|
+
sidebar_label: Transform Route Parameters
|
|
4
|
+
description:
|
|
5
|
+
Learn how to use an inbound policy to transform route parameter values before
|
|
6
|
+
the URL Rewrite handler forwards the request to your backend.
|
|
7
|
+
tags:
|
|
8
|
+
- custom-code
|
|
9
|
+
- backends
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
This guide explains how to transform incoming route parameter values in an
|
|
13
|
+
inbound policy before the [URL Rewrite handler](../handlers/url-rewrite.mdx)
|
|
14
|
+
uses them to build the upstream URL. This pattern is useful when your public API
|
|
15
|
+
paths use different naming conventions than your internal backend.
|
|
16
|
+
|
|
17
|
+
## Overview
|
|
18
|
+
|
|
19
|
+
When you use the URL Rewrite handler, it builds the upstream URL by
|
|
20
|
+
interpolating values like `${params.resourceType}` directly from the incoming
|
|
21
|
+
route parameters. Sometimes, however, you need to **change** those values before
|
|
22
|
+
the rewrite happens. Common scenarios include:
|
|
23
|
+
|
|
24
|
+
- **Value mapping** — translating a public-facing parameter like `order` to an
|
|
25
|
+
internal value like `customerorder`
|
|
26
|
+
- **Case normalization** — converting `Products` to `products` before forwarding
|
|
27
|
+
- **Path translation** — mapping user-friendly slugs to internal identifiers
|
|
28
|
+
|
|
29
|
+
The recommended approach is to read the route parameters in an inbound policy,
|
|
30
|
+
transform them, store the results on
|
|
31
|
+
[`context.custom`](../programmable-api/zuplo-context.mdx), and reference the
|
|
32
|
+
transformed values in the URL Rewrite pattern.
|
|
33
|
+
|
|
34
|
+
## Step-by-Step Example
|
|
35
|
+
|
|
36
|
+
The solution has three parts: an **inbound policy** that reads `request.params`
|
|
37
|
+
and stores transformed values on `context.custom`, a **URL Rewrite handler**
|
|
38
|
+
that references those values using `${context.custom.*}` in the
|
|
39
|
+
`rewritePattern`, and **route configuration** that wires the two together.
|
|
40
|
+
|
|
41
|
+
Imagine your public API exposes a route like `/api/:resourceType/:resourceId`,
|
|
42
|
+
but your backend expects the resource type to be prefixed with `customer`. A
|
|
43
|
+
request to `/api/order/123` should be forwarded to
|
|
44
|
+
`https://backend.example.com/api/customerorder/123`.
|
|
45
|
+
|
|
46
|
+
### 1. Write the Inbound Policy
|
|
47
|
+
|
|
48
|
+
Create a custom inbound policy that reads the route parameters, transforms the
|
|
49
|
+
values, and stores them on `context.custom`:
|
|
50
|
+
|
|
51
|
+
```ts title="modules/transform-params.ts"
|
|
52
|
+
import { ZuploContext, ZuploRequest } from "@zuplo/runtime";
|
|
53
|
+
|
|
54
|
+
export default async function (
|
|
55
|
+
request: ZuploRequest,
|
|
56
|
+
context: ZuploContext,
|
|
57
|
+
options: any,
|
|
58
|
+
policyName: string,
|
|
59
|
+
): Promise<ZuploRequest | Response> {
|
|
60
|
+
// Read the original route parameter
|
|
61
|
+
const resourceType = request.params.resourceType;
|
|
62
|
+
|
|
63
|
+
// Transform the value — prefix with "customer"
|
|
64
|
+
const transformedResourceType = `customer${resourceType}`;
|
|
65
|
+
|
|
66
|
+
// Store the transformed value on context.custom
|
|
67
|
+
context.custom.transformedResourceType = transformedResourceType;
|
|
68
|
+
|
|
69
|
+
context.log.info({
|
|
70
|
+
message: "Transformed route parameter",
|
|
71
|
+
original: resourceType,
|
|
72
|
+
transformed: transformedResourceType,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return request;
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### 2. Register the Policy
|
|
80
|
+
|
|
81
|
+
Add the policy to `config/policies.json`:
|
|
82
|
+
|
|
83
|
+
```json title="config/policies.json"
|
|
84
|
+
{
|
|
85
|
+
"policies": [
|
|
86
|
+
{
|
|
87
|
+
"name": "transform-params",
|
|
88
|
+
"policyType": "custom-code-inbound",
|
|
89
|
+
"handler": {
|
|
90
|
+
"export": "default",
|
|
91
|
+
"module": "$import(./modules/transform-params)"
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### 3. Configure the Route
|
|
99
|
+
|
|
100
|
+
Define the route in `config/routes.oas.json` with the inbound policy and a URL
|
|
101
|
+
Rewrite handler that references `context.custom`:
|
|
102
|
+
|
|
103
|
+
```json title="config/routes.oas.json"
|
|
104
|
+
{
|
|
105
|
+
"paths": {
|
|
106
|
+
"/api/{resourceType}/{resourceId}": {
|
|
107
|
+
"x-zuplo-path": {
|
|
108
|
+
"pathMode": "open-api"
|
|
109
|
+
},
|
|
110
|
+
"get": {
|
|
111
|
+
"summary": "Get resource by type and ID",
|
|
112
|
+
"x-zuplo-route": {
|
|
113
|
+
"corsPolicy": "none",
|
|
114
|
+
"handler": {
|
|
115
|
+
"export": "urlRewriteHandler",
|
|
116
|
+
"module": "$import(@zuplo/runtime)",
|
|
117
|
+
"options": {
|
|
118
|
+
"rewritePattern": "https://backend.example.com/api/${context.custom.transformedResourceType}/${params.resourceId}"
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
"policies": {
|
|
122
|
+
"inbound": ["transform-params"]
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
With this configuration, a request to `/api/order/123` flows through the
|
|
132
|
+
pipeline as follows:
|
|
133
|
+
|
|
134
|
+
1. The route matches with `params.resourceType = "order"` and
|
|
135
|
+
`params.resourceId = "123"`
|
|
136
|
+
2. The `transform-params` inbound policy runs and sets
|
|
137
|
+
`context.custom.transformedResourceType = "customerorder"`
|
|
138
|
+
3. The URL Rewrite handler builds the upstream URL:
|
|
139
|
+
`https://backend.example.com/api/customerorder/123`
|
|
140
|
+
|
|
141
|
+
## Common Pitfall: Modifying `request.params` Directly
|
|
142
|
+
|
|
143
|
+
:::caution
|
|
144
|
+
|
|
145
|
+
Do not try to transform route parameters by constructing a new `ZuploRequest`
|
|
146
|
+
with modified `params` and expecting the URL Rewrite handler to pick them up.
|
|
147
|
+
|
|
148
|
+
:::
|
|
149
|
+
|
|
150
|
+
A common first attempt is to create a new
|
|
151
|
+
[`ZuploRequest`](../programmable-api/zuplo-request.mdx) with different `params`:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
// ⚠️ This approach does NOT work as expected with URL Rewrite
|
|
155
|
+
const newRequest = new ZuploRequest(request, {
|
|
156
|
+
params: {
|
|
157
|
+
...request.params,
|
|
158
|
+
resourceType: "customerorder",
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
return newRequest;
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
In practice, the URL Rewrite handler evaluates `${params.*}` against the
|
|
165
|
+
route-level parameters rather than the request object returned by a policy. This
|
|
166
|
+
means the rewritten URL may contain `undefined` segments instead of your
|
|
167
|
+
transformed values. Use `context.custom` for reliable interpolation of
|
|
168
|
+
transformed values — the URL Rewrite handler's `rewritePattern` fully supports
|
|
169
|
+
`${context.custom.*}`, and values set in an inbound policy are available when
|
|
170
|
+
the handler runs.
|
|
171
|
+
|
|
172
|
+
## Variations
|
|
173
|
+
|
|
174
|
+
### Using a Lookup Map
|
|
175
|
+
|
|
176
|
+
For more complex mappings where the transformation is not a simple string
|
|
177
|
+
operation, use a lookup object:
|
|
178
|
+
|
|
179
|
+
```ts title="modules/transform-params-map.ts"
|
|
180
|
+
import { ZuploContext, ZuploRequest, HttpProblems } from "@zuplo/runtime";
|
|
181
|
+
|
|
182
|
+
// Map public resource types to internal names
|
|
183
|
+
const RESOURCE_TYPE_MAP: Record<string, string> = {
|
|
184
|
+
order: "customerorder",
|
|
185
|
+
invoice: "billing-invoice",
|
|
186
|
+
profile: "user-profile",
|
|
187
|
+
subscription: "recurring-plan",
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export default async function (
|
|
191
|
+
request: ZuploRequest,
|
|
192
|
+
context: ZuploContext,
|
|
193
|
+
options: any,
|
|
194
|
+
policyName: string,
|
|
195
|
+
): Promise<ZuploRequest | Response> {
|
|
196
|
+
const resourceType = request.params.resourceType;
|
|
197
|
+
const mappedType = RESOURCE_TYPE_MAP[resourceType];
|
|
198
|
+
|
|
199
|
+
if (!mappedType) {
|
|
200
|
+
return HttpProblems.notFound(request, context, {
|
|
201
|
+
detail: `Unknown resource type: ${resourceType}`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
context.custom.transformedResourceType = mappedType;
|
|
206
|
+
|
|
207
|
+
return request;
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Transforming Multiple Parameters
|
|
212
|
+
|
|
213
|
+
You can transform any number of route parameters and store each on
|
|
214
|
+
`context.custom`. Reference them individually in the rewrite pattern:
|
|
215
|
+
|
|
216
|
+
```ts title="modules/transform-multiple-params.ts"
|
|
217
|
+
import { ZuploContext, ZuploRequest } from "@zuplo/runtime";
|
|
218
|
+
|
|
219
|
+
export default async function (
|
|
220
|
+
request: ZuploRequest,
|
|
221
|
+
context: ZuploContext,
|
|
222
|
+
options: any,
|
|
223
|
+
policyName: string,
|
|
224
|
+
): Promise<ZuploRequest | Response> {
|
|
225
|
+
// Normalize casing
|
|
226
|
+
context.custom.version = request.params.version?.toLowerCase();
|
|
227
|
+
|
|
228
|
+
// Map resource type
|
|
229
|
+
context.custom.resource =
|
|
230
|
+
request.params.resource === "users" ? "customers" : request.params.resource;
|
|
231
|
+
|
|
232
|
+
return request;
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Then use both values in the rewrite pattern:
|
|
237
|
+
|
|
238
|
+
```json
|
|
239
|
+
{
|
|
240
|
+
"rewritePattern": "https://backend.example.com/${context.custom.version}/${context.custom.resource}/${params.id}"
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Combining with Body Transformation
|
|
245
|
+
|
|
246
|
+
If your API also needs to transform values in the request body alongside route
|
|
247
|
+
parameters, you can handle both in the same inbound policy. Create a new
|
|
248
|
+
`ZuploRequest` with a modified body while storing the route parameter
|
|
249
|
+
transformations on `context.custom`:
|
|
250
|
+
|
|
251
|
+
```ts title="modules/transform-params-and-body.ts"
|
|
252
|
+
import { ZuploContext, ZuploRequest } from "@zuplo/runtime";
|
|
253
|
+
|
|
254
|
+
export default async function (
|
|
255
|
+
request: ZuploRequest,
|
|
256
|
+
context: ZuploContext,
|
|
257
|
+
options: any,
|
|
258
|
+
policyName: string,
|
|
259
|
+
): Promise<ZuploRequest | Response> {
|
|
260
|
+
// Transform route parameter
|
|
261
|
+
context.custom.transformedResourceType = `customer${request.params.resourceType}`;
|
|
262
|
+
|
|
263
|
+
// Transform the request body if present
|
|
264
|
+
if (request.headers.get("content-type")?.includes("application/json")) {
|
|
265
|
+
const body = await request.json();
|
|
266
|
+
|
|
267
|
+
// Map fields in the body to match the backend schema
|
|
268
|
+
const transformedBody = {
|
|
269
|
+
...body,
|
|
270
|
+
type: context.custom.transformedResourceType,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// Return a new request with the modified body
|
|
274
|
+
return new ZuploRequest(request, {
|
|
275
|
+
body: JSON.stringify(transformedBody),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return request;
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## Best Practices
|
|
284
|
+
|
|
285
|
+
- **Use descriptive keys on `context.custom`** — names like
|
|
286
|
+
`context.custom.transformedResourceType` are easier to debug than generic keys
|
|
287
|
+
like `context.custom.value`
|
|
288
|
+
- **Log transformations** — use `context.log` to record original and transformed
|
|
289
|
+
values so you can trace issues in production
|
|
290
|
+
- **Validate before transforming** — return an appropriate error response (using
|
|
291
|
+
[`HttpProblems`](../programmable-api/http-problems.mdx)) if a parameter value
|
|
292
|
+
is unexpected, rather than forwarding bad data to your backend
|
|
293
|
+
- **Keep the policy focused** — if your transformation logic is complex,
|
|
294
|
+
consider splitting it into a separate utility module imported by the policy
|
|
295
|
+
|
|
296
|
+
## Next Steps
|
|
297
|
+
|
|
298
|
+
- [URL Rewrite Handler](../handlers/url-rewrite.mdx) — full reference for
|
|
299
|
+
rewrite patterns and available interpolation variables
|
|
300
|
+
- [Custom Code Patterns](../articles/custom-code-patterns.md) — common patterns
|
|
301
|
+
for writing inbound policies, outbound policies, and handlers
|
|
302
|
+
- [ZuploContext](../programmable-api/zuplo-context.mdx) — reference for
|
|
303
|
+
`context.custom` and other context properties
|
|
304
|
+
- [ZuploRequest](../programmable-api/zuplo-request.mdx) — reference for
|
|
305
|
+
`request.params` and constructing new requests
|
|
306
|
+
- [User-Based Backend Routing](./user-based-backend-routing.mdx) — a related
|
|
307
|
+
pattern using `context.custom` with URL Rewrite for routing by user identity
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Cross App Access (XAA)"
|
|
3
|
+
sidebar_label: "Overview"
|
|
4
|
+
description: |
|
|
5
|
+
What Cross App Access (XAA) and the Identity Assertion JWT Authorization Grant
|
|
6
|
+
(ID-JAG) are, the problem they solve for AI agents and MCP, and how the Zuplo
|
|
7
|
+
MCP Gateway performs the token exchange so your MCP servers and clients don't
|
|
8
|
+
have to.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
:::note{title="Beta"}
|
|
12
|
+
|
|
13
|
+
Cross App Access support is in beta and builds on the
|
|
14
|
+
[MCP Gateway](../introduction.mdx), which is also in beta. The configuration
|
|
15
|
+
model and policy options may change before general availability.
|
|
16
|
+
|
|
17
|
+
:::
|
|
18
|
+
|
|
19
|
+
Cross App Access (XAA) lets one application reach another application's API on a
|
|
20
|
+
user's behalf **through the identity provider both apps already trust** —
|
|
21
|
+
instead of running a separate, point-to-point OAuth connection between them. For
|
|
22
|
+
AI agents and MCP, that means an agent can call a protected upstream MCP server
|
|
23
|
+
for a user without the user re-consenting to every app pair, and without
|
|
24
|
+
credentials sprawling outside the identity provider's view.
|
|
25
|
+
|
|
26
|
+
The Zuplo MCP Gateway sits in the middle of this flow and runs the XAA token
|
|
27
|
+
exchange for you. An MCP client connects to the gateway with ordinary MCP OAuth;
|
|
28
|
+
the gateway, acting as the XAA _requesting app_, mints the cross-app grant from
|
|
29
|
+
your identity provider and redeems it at the upstream's authorization server.
|
|
30
|
+
Neither the MCP client nor the upstream MCP server has to implement XAA itself.
|
|
31
|
+
|
|
32
|
+
## The problem XAA solves
|
|
33
|
+
|
|
34
|
+
Single sign-on (SSO) solves _login_: a user authenticates once with an identity
|
|
35
|
+
provider (Okta, Microsoft Entra, Auth0) and gets into every connected app. SSO
|
|
36
|
+
does **not** solve _app-to-app API access_. When one app needs to call another
|
|
37
|
+
app's API on the user's behalf, the two apps today run a direct OAuth flow
|
|
38
|
+
between themselves. The
|
|
39
|
+
[IETF draft](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)
|
|
40
|
+
describes this as a connection that "bypasses the trusted identity provider" and
|
|
41
|
+
is "invisible to the identity provider managing the ecosystem."
|
|
42
|
+
|
|
43
|
+
AI agents make this gap urgent. An agent in one app increasingly needs to reach
|
|
44
|
+
into other SaaS apps' data on the user's behalf. The naive approach produces:
|
|
45
|
+
|
|
46
|
+
- **Consent-screen sprawl** — the user re-authorizes every agent against every
|
|
47
|
+
downstream app individually.
|
|
48
|
+
- **Credential sprawl** — long-lived tokens scattered per app pair, invisible to
|
|
49
|
+
IT.
|
|
50
|
+
- **No central governance** — security teams can't see or control what connects
|
|
51
|
+
to what, and offboarding means hunting access down service by service.
|
|
52
|
+
|
|
53
|
+
XAA routes app-to-app access back through the identity provider both apps
|
|
54
|
+
already trust. The IdP becomes the single place where IT decides which app can
|
|
55
|
+
talk to which app, enforces conditional-access policy, and audits every
|
|
56
|
+
delegation — while the upstream app's authorization server stays the
|
|
57
|
+
access-token issuer. The trust boundary isn't broken; it's brokered.
|
|
58
|
+
|
|
59
|
+
## How the flow works
|
|
60
|
+
|
|
61
|
+
XAA is defined by the **Identity Assertion JWT Authorization Grant (ID-JAG)** —
|
|
62
|
+
`draft-ietf-oauth-identity-assertion-authz-grant`. It chains two OAuth exchanges
|
|
63
|
+
across two authorization servers:
|
|
64
|
+
|
|
65
|
+
1. **Identity assertion → ID-JAG**, at the identity provider, using
|
|
66
|
+
[RFC 8693 token exchange](https://www.rfc-editor.org/rfc/rfc8693). The
|
|
67
|
+
requesting app presents the user's ID token (the identity assertion) and asks
|
|
68
|
+
for a grant scoped to one specific target authorization server. The IdP
|
|
69
|
+
evaluates policy and returns a short-lived, audience-restricted **ID-JAG** —
|
|
70
|
+
a signed JWT with the header `typ: oauth-id-jag+jwt`.
|
|
71
|
+
2. **ID-JAG → access token**, at the upstream's resource authorization server,
|
|
72
|
+
using the
|
|
73
|
+
[RFC 7523 JWT bearer grant](https://www.rfc-editor.org/rfc/rfc7523). The
|
|
74
|
+
requesting app presents the ID-JAG as an `assertion` and receives a normal
|
|
75
|
+
access token for the upstream API.
|
|
76
|
+
|
|
77
|
+
The ID-JAG is a _grant_, not an access token — it can't call an API directly. It
|
|
78
|
+
exists only to be redeemed in the second exchange.
|
|
79
|
+
|
|
80
|
+
When the Zuplo gateway fronts an XAA-protected upstream, the gateway plays the
|
|
81
|
+
**requesting app** for both exchanges:
|
|
82
|
+
|
|
83
|
+
<Diagram height="h-80">
|
|
84
|
+
<DiagramNode id="client">MCP Client</DiagramNode>
|
|
85
|
+
<DiagramGroup id="gateway" label="Zuplo MCP Gateway (requesting app)">
|
|
86
|
+
<DiagramNode id="inbound" variant="zuplo">
|
|
87
|
+
OAuth 2.1 server
|
|
88
|
+
</DiagramNode>
|
|
89
|
+
<DiagramNode id="requestor" variant="zuplo">
|
|
90
|
+
XAA requestor
|
|
91
|
+
</DiagramNode>
|
|
92
|
+
</DiagramGroup>
|
|
93
|
+
<DiagramNode id="idp">Identity Provider</DiagramNode>
|
|
94
|
+
<DiagramGroup id="upstream" label="Upstream app">
|
|
95
|
+
<DiagramNode id="ras">Resource AS</DiagramNode>
|
|
96
|
+
<DiagramNode id="mcp">MCP server</DiagramNode>
|
|
97
|
+
</DiagramGroup>
|
|
98
|
+
<DiagramEdge from="client" to="inbound" label="MCP OAuth (no XAA)" />
|
|
99
|
+
<DiagramEdge from="requestor" to="idp" label="1. ID token → ID-JAG" />
|
|
100
|
+
<DiagramEdge from="requestor" to="ras" label="2. ID-JAG → access token" />
|
|
101
|
+
<DiagramEdge from="requestor" to="mcp" label="3. Bearer token" />
|
|
102
|
+
</Diagram>
|
|
103
|
+
|
|
104
|
+
Step by step, for a tool call to an XAA-protected upstream route:
|
|
105
|
+
|
|
106
|
+
1. The MCP client connects to the gateway route with **ordinary MCP OAuth** —
|
|
107
|
+
discovery, PKCE, and a bearer token issued by the gateway. This leg is _not_
|
|
108
|
+
XAA. During the gateway's browser-login step, the user authenticates with the
|
|
109
|
+
identity provider, and the gateway captures the user's IdP identity
|
|
110
|
+
assertion.
|
|
111
|
+
2. On a tool call, the gateway exchanges the user's ID token at the IdP for an
|
|
112
|
+
**ID-JAG** audience-restricted to the upstream's resource authorization
|
|
113
|
+
server (RFC 8693 token exchange).
|
|
114
|
+
3. The gateway redeems the ID-JAG at the upstream resource authorization server
|
|
115
|
+
for an upstream **access token** (RFC 7523 JWT bearer grant).
|
|
116
|
+
4. The gateway forwards the tool call to the upstream MCP server with
|
|
117
|
+
`Authorization: Bearer <upstream access token>` and caches the upstream token
|
|
118
|
+
per user for subsequent calls.
|
|
119
|
+
|
|
120
|
+
:::note{title="In requesting-app mode, the client → gateway leg is plain OAuth"}
|
|
121
|
+
|
|
122
|
+
This is the most common point of confusion. In the flow above, the MCP client
|
|
123
|
+
never speaks XAA — it runs the standard MCP authorization flow against the
|
|
124
|
+
gateway, and all ID-JAG exchanges happen on the gateway's _outbound_ side. A
|
|
125
|
+
client doesn't need any XAA support to benefit from it.
|
|
126
|
+
|
|
127
|
+
:::
|
|
128
|
+
|
|
129
|
+
## What the gateway does for you
|
|
130
|
+
|
|
131
|
+
In the XAA flow the gateway plays the requesting app, so the MCP client and the
|
|
132
|
+
upstream MCP server are each insulated from the protocol:
|
|
133
|
+
|
|
134
|
+
- **The MCP client** does nothing new. It runs the same MCP OAuth flow it would
|
|
135
|
+
for any gateway route. Claude, Cursor, ChatGPT, VS Code, and any
|
|
136
|
+
spec-compliant client work unchanged.
|
|
137
|
+
- **The two-exchange dance** — the RFC 8693 token exchange at the IdP and the
|
|
138
|
+
RFC 7523 JWT-bearer redemption at the upstream — runs inside the gateway. You
|
|
139
|
+
configure endpoints and credentials; the gateway mints, redeems, caches, and
|
|
140
|
+
refreshes.
|
|
141
|
+
- **Per-user identity** is preserved end to end. The IdP sees the specific user,
|
|
142
|
+
the upstream sees a token minted for that user, and gateway analytics record
|
|
143
|
+
the same subject.
|
|
144
|
+
|
|
145
|
+
## Glossary
|
|
146
|
+
|
|
147
|
+
- **Identity assertion** — Proof the IdP authenticated the user, carried as an
|
|
148
|
+
OIDC ID token (`urn:ietf:params:oauth:token-type:id_token`) or SAML 2.0
|
|
149
|
+
assertion. It's the _input_ to the XAA flow.
|
|
150
|
+
- **ID-JAG (Identity Assertion JWT Authorization Grant)** — A short-lived signed
|
|
151
|
+
JWT the IdP mints, audience-restricted to one resource authorization server.
|
|
152
|
+
Used once to obtain an access token there. JWT header `typ: oauth-id-jag+jwt`;
|
|
153
|
+
token type `urn:ietf:params:oauth:token-type:id-jag`. It is **not** an access
|
|
154
|
+
token.
|
|
155
|
+
- **Identity provider (IdP)** — The SSO authority both apps trust (Okta, Entra,
|
|
156
|
+
Auth0). In XAA it also brokers cross-app access by issuing ID-JAGs after
|
|
157
|
+
evaluating policy. Identified by `iss` in the ID-JAG.
|
|
158
|
+
- **Resource authorization server** — The _target_ app's OAuth server. It
|
|
159
|
+
consumes the ID-JAG via the JWT-bearer grant and issues the real access token.
|
|
160
|
+
Identified by `aud` in the ID-JAG. In Zuplo's outbound flow, this is the
|
|
161
|
+
upstream's server, not the gateway.
|
|
162
|
+
- **Requesting app** — The party that obtains and redeems the ID-JAG. When the
|
|
163
|
+
gateway fronts an XAA-protected upstream, the gateway is the requesting app.
|
|
164
|
+
- **Token exchange (RFC 8693)** — The grant used at the IdP to swap the identity
|
|
165
|
+
assertion for the ID-JAG.
|
|
166
|
+
- **JWT bearer grant (RFC 7523)** — The grant used at the resource authorization
|
|
167
|
+
server to redeem the ID-JAG for an access token.
|
|
168
|
+
|
|
169
|
+
## When to use XAA
|
|
170
|
+
|
|
171
|
+
Reach for XAA when:
|
|
172
|
+
|
|
173
|
+
- An upstream MCP server is protected by an **enterprise resource authorization
|
|
174
|
+
server** that accepts ID-JAGs (the "enterprise-managed authorization"
|
|
175
|
+
pattern), and you want the gateway to broker access through your IdP rather
|
|
176
|
+
than running a separate OAuth client per upstream.
|
|
177
|
+
- You need **central governance** — one place in the IdP to grant, audit, and
|
|
178
|
+
revoke which apps can reach which APIs on a user's behalf.
|
|
179
|
+
|
|
180
|
+
For an upstream that uses plain per-user or shared OAuth (Linear, Notion,
|
|
181
|
+
Stripe, and most SaaS MCP servers today), use
|
|
182
|
+
[upstream OAuth](../auth/upstream-oauth.mdx) instead — XAA isn't required.
|
|
183
|
+
|
|
184
|
+
## Try it and configure it
|
|
185
|
+
|
|
186
|
+
- [Quickstart](./quickstart.mdx) — see XAA work end to end in minutes using
|
|
187
|
+
Okta's hosted [xaa.dev](https://xaa.dev) playground, with no identity tenant
|
|
188
|
+
required.
|
|
189
|
+
- [Configuration reference](./policy-reference.mdx) — every `idJag` option on
|
|
190
|
+
the token-exchange policy.
|
|
191
|
+
|
|
192
|
+
## Learn more
|
|
193
|
+
|
|
194
|
+
- [Identity Assertion JWT Authorization Grant (IETF draft)](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/)
|
|
195
|
+
— the ID-JAG specification. Note this is an active draft, not yet a finalized
|
|
196
|
+
RFC.
|
|
197
|
+
- [MCP authorization spec](https://modelcontextprotocol.io/specification/latest/basic/authorization)
|
|
198
|
+
and the
|
|
199
|
+
[Enterprise-Managed Authorization extension](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization)
|
|
200
|
+
— where ID-JAG plugs into MCP.
|
|
201
|
+
- [Okta — Cross App Access](https://developer.okta.com/blog/2025/09/03/cross-app-access)
|
|
202
|
+
— Okta's explainer and walkthrough.
|
|
203
|
+
- [How the MCP Gateway works](../how-it-works.mdx) — the gateway's two OAuth
|
|
204
|
+
surfaces and request lifecycle.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Cross App Access configuration reference"
|
|
3
|
+
sidebar_label: "Configuration reference"
|
|
4
|
+
description: |
|
|
5
|
+
Every configuration option for Cross App Access (XAA) on the Zuplo MCP Gateway
|
|
6
|
+
— the id-jag mode on the token-exchange policy, where the gateway acts as the
|
|
7
|
+
XAA requesting app.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
Cross App Access is configured on the
|
|
11
|
+
[`mcp-token-exchange-inbound`](#gateway-as-requesting-app) policy. Setting
|
|
12
|
+
`authMode: "id-jag"` and providing an `idJag` block makes the gateway act as the
|
|
13
|
+
XAA **requesting app**: it mints an ID-JAG from your IdP and redeems it at an
|
|
14
|
+
upstream resource authorization server. This is the configuration the
|
|
15
|
+
[quickstart](./quickstart.mdx) uses.
|
|
16
|
+
|
|
17
|
+
:::note
|
|
18
|
+
|
|
19
|
+
The authoritative source for these options is the policy's runtime schema. The
|
|
20
|
+
generated `mcp-token-exchange-inbound` reference page predates `id-jag` mode;
|
|
21
|
+
the options below reflect the runtime behavior.
|
|
22
|
+
|
|
23
|
+
:::
|
|
24
|
+
|
|
25
|
+
## Gateway as requesting app
|
|
26
|
+
|
|
27
|
+
Set `authMode: "id-jag"` on a `mcp-token-exchange-inbound` policy and provide an
|
|
28
|
+
`idJag` block. Attach the policy to the upstream route after the inbound MCP
|
|
29
|
+
OAuth policy.
|
|
30
|
+
|
|
31
|
+
```jsonc title="config/policies.json"
|
|
32
|
+
{
|
|
33
|
+
"name": "id-jag-upstream",
|
|
34
|
+
"policyType": "mcp-token-exchange-inbound",
|
|
35
|
+
"handler": {
|
|
36
|
+
"module": "$import(@zuplo/runtime/mcp-gateway)",
|
|
37
|
+
"export": "McpTokenExchangeInboundPolicy",
|
|
38
|
+
"options": {
|
|
39
|
+
"displayName": "Upstream",
|
|
40
|
+
"authMode": "id-jag",
|
|
41
|
+
"idJag": {
|
|
42
|
+
"scopes": ["mcp:tools"],
|
|
43
|
+
"scopeDelimiter": " ",
|
|
44
|
+
"idp": {
|
|
45
|
+
"tokenUrl": "https://idp.example.com/token",
|
|
46
|
+
"clientAuth": {
|
|
47
|
+
"method": "client_secret_post",
|
|
48
|
+
"clientId": "$env(IDP_CLIENT_ID)",
|
|
49
|
+
"clientSecret": "$env(IDP_CLIENT_SECRET)",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
"resourceAs": {
|
|
53
|
+
"tokenUrl": "https://upstream.example.com/token",
|
|
54
|
+
"audience": "https://upstream.example.com",
|
|
55
|
+
"resource": "https://upstream.example.com/mcp",
|
|
56
|
+
"clientAuth": {
|
|
57
|
+
"method": "client_secret_post",
|
|
58
|
+
"clientId": "$env(RESOURCE_AS_CLIENT_ID)",
|
|
59
|
+
"clientSecret": "$env(RESOURCE_AS_CLIENT_SECRET)",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### idJag options
|
|
69
|
+
|
|
70
|
+
| Option | Type | Default | Description |
|
|
71
|
+
| ---------------- | ---------- | ------- | -------------------------------------------------------------------- |
|
|
72
|
+
| `scopes` | `string[]` | `[]` | Scopes requested in both exchanges. |
|
|
73
|
+
| `scopeDelimiter` | `string` | `" "` | Delimiter used to join scopes. |
|
|
74
|
+
| `idp` | object | — | Where the gateway mints the ID-JAG (RFC 8693 token exchange). |
|
|
75
|
+
| `resourceAs` | object | — | Where the gateway redeems the ID-JAG for an access token (RFC 7523). |
|
|
76
|
+
|
|
77
|
+
### idp
|
|
78
|
+
|
|
79
|
+
The identity provider that issues the ID-JAG.
|
|
80
|
+
|
|
81
|
+
| Option | Type | Description |
|
|
82
|
+
| ------------ | ------ | ----------------------------------------------------- |
|
|
83
|
+
| `tokenUrl` | string | The IdP token endpoint. |
|
|
84
|
+
| `clientAuth` | object | How the gateway authenticates to the IdP (see below). |
|
|
85
|
+
|
|
86
|
+
### resourceAs
|
|
87
|
+
|
|
88
|
+
The upstream's resource authorization server that issues the access token.
|
|
89
|
+
|
|
90
|
+
| Option | Type | Description |
|
|
91
|
+
| ------------ | ------ | --------------------------------------------------------------------------------------------------------------------- |
|
|
92
|
+
| `tokenUrl` | string | The resource authorization server's token endpoint. |
|
|
93
|
+
| `audience` | string | **Required.** The resource AS identifier; sent as the token-exchange `audience` and becomes the ID-JAG `aud`. |
|
|
94
|
+
| `resource` | string | Optional [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707) resource indicator. Defaults to the route's upstream URL. |
|
|
95
|
+
| `clientAuth` | object | How the gateway authenticates to the resource AS (see below). |
|
|
96
|
+
|
|
97
|
+
### clientAuth
|
|
98
|
+
|
|
99
|
+
Both `idp.clientAuth` and `resourceAs.clientAuth` take the same shape. The
|
|
100
|
+
`method` selects how the gateway authenticates:
|
|
101
|
+
|
|
102
|
+
```jsonc
|
|
103
|
+
// client_secret_post (or client_secret_basic)
|
|
104
|
+
"clientAuth": {
|
|
105
|
+
"method": "client_secret_post",
|
|
106
|
+
"clientId": "...",
|
|
107
|
+
"clientSecret": "$env(...)"
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```jsonc
|
|
112
|
+
// private_key_jwt
|
|
113
|
+
"clientAuth": {
|
|
114
|
+
"method": "private_key_jwt",
|
|
115
|
+
"clientId": "...",
|
|
116
|
+
"privateKeyPem": "$env(...)",
|
|
117
|
+
"algorithm": "RS256",
|
|
118
|
+
"keyId": "...",
|
|
119
|
+
"expiresInSeconds": 300
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
| Option | Type | Default | Applies to | Description |
|
|
124
|
+
| ------------------ | ------ | ------- | ----------------- | ------------------------------------------------------------------ |
|
|
125
|
+
| `method` | enum | — | all | `client_secret_post`, `client_secret_basic`, or `private_key_jwt`. |
|
|
126
|
+
| `clientId` | string | — | all | The OAuth client ID. |
|
|
127
|
+
| `clientSecret` | string | — | secret methods | The OAuth client secret. |
|
|
128
|
+
| `privateKeyPem` | string | — | `private_key_jwt` | PEM private key used to sign the client-assertion JWT. |
|
|
129
|
+
| `algorithm` | enum | `RS256` | `private_key_jwt` | `RS256`/`RS384`/`RS512`/`ES256`/`ES384`/`ES512`. |
|
|
130
|
+
| `keyId` | string | — | `private_key_jwt` | Optional `kid` header on the client assertion. |
|
|
131
|
+
| `audience` | string | — | `private_key_jwt` | Optional audience override for the client assertion. |
|
|
132
|
+
| `expiresInSeconds` | number | `300` | `private_key_jwt` | Client-assertion lifetime, max `3600`. |
|
|
133
|
+
|
|
134
|
+
### What the gateway does at request time
|
|
135
|
+
|
|
136
|
+
On a tool call to the route, the gateway:
|
|
137
|
+
|
|
138
|
+
1. Resolves the user's stored IdP identity assertion (bound during the inbound
|
|
139
|
+
browser login). If absent or expired, it returns a connect-required error and
|
|
140
|
+
refreshes the subject token when it can.
|
|
141
|
+
2. Runs an RFC 8693 token exchange at `idp.tokenUrl`, requesting an ID-JAG
|
|
142
|
+
audience-restricted to `resourceAs.audience`.
|
|
143
|
+
3. Redeems the ID-JAG at `resourceAs.tokenUrl` as an RFC 7523 JWT-bearer grant
|
|
144
|
+
to get the upstream access token.
|
|
145
|
+
4. Caches the upstream token per user and forwards the tool call with
|
|
146
|
+
`Authorization: Bearer <upstream token>`.
|
|
147
|
+
|
|
148
|
+
## Notes and limitations
|
|
149
|
+
|
|
150
|
+
- The gateway issues **opaque** access tokens, not JWTs.
|
|
151
|
+
- **DPoP is not supported.** Requests with a `DPoP` header are rejected.
|
|
152
|
+
- XAA requires a prior inbound browser login so the gateway has an IdP identity
|
|
153
|
+
assertion to exchange.
|
|
154
|
+
|
|
155
|
+
## Related
|
|
156
|
+
|
|
157
|
+
- [Overview](./overview.mdx) — the protocol and the gateway's role.
|
|
158
|
+
- [Quickstart](./quickstart.mdx) — a working `id-jag` configuration on the
|
|
159
|
+
playground.
|
|
160
|
+
- [`mcp-token-exchange-inbound` policy](/policies/mcp-token-exchange-inbound) —
|
|
161
|
+
the base token-exchange policy reference.
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Cross App Access quickstart"
|
|
3
|
+
sidebar_label: "Quickstart"
|
|
4
|
+
description: |
|
|
5
|
+
Build a Zuplo MCP Gateway that reaches an XAA-protected MCP server in the
|
|
6
|
+
Zuplo Portal — no repository to clone. The gateway fronts Okta's hosted
|
|
7
|
+
xaa.dev Todo0 server and performs the Cross App Access (XAA) token exchange on
|
|
8
|
+
every request.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
This quickstart builds a working Cross App Access (XAA) gateway entirely in the
|
|
12
|
+
Zuplo Portal. An MCP client connects to the gateway with ordinary OAuth; the
|
|
13
|
+
gateway then performs the XAA token exchange against
|
|
14
|
+
[Okta's hosted xaa.dev playground](https://xaa.dev) and reaches the protected
|
|
15
|
+
Todo0 MCP server on the user's behalf.
|
|
16
|
+
|
|
17
|
+
There is no repository to clone and no identity tenant to stand up. Everything
|
|
18
|
+
runs in the portal against the public xaa.dev playground.
|
|
19
|
+
|
|
20
|
+
## Why xaa.dev
|
|
21
|
+
|
|
22
|
+
[xaa.dev](https://xaa.dev) is Okta's hosted Cross App Access playground: a
|
|
23
|
+
public identity provider, resource authorization server, and Todo0 MCP server
|
|
24
|
+
that are already wired together for the XAA token exchange. Standing up your own
|
|
25
|
+
XAA stack means provisioning an IdP, a resource authorization server, and a
|
|
26
|
+
protected MCP server — and configuring the trust relationships between all
|
|
27
|
+
three. The playground gives you all of that for free, so today it's the fastest
|
|
28
|
+
way to see a real Cross App Access flow end to end. Once the gateway works
|
|
29
|
+
against xaa.dev, swapping in your own identity provider and upstream is just a
|
|
30
|
+
change of endpoints and credentials.
|
|
31
|
+
|
|
32
|
+
## What you'll build
|
|
33
|
+
|
|
34
|
+
A single gateway route with two policies does all the work:
|
|
35
|
+
|
|
36
|
+
- An **MCP OAuth** policy (`mcp-oauth-inbound`) secures the client → gateway
|
|
37
|
+
leg. The client authenticates to the gateway with plain MCP OAuth — this leg
|
|
38
|
+
is not XAA.
|
|
39
|
+
- A **token-exchange** policy (`mcp-token-exchange-inbound`) in `id-jag` mode
|
|
40
|
+
performs the outbound XAA exchange to the upstream: it mints an ID-JAG at the
|
|
41
|
+
playground identity provider (IdenX), redeems it at the resource authorization
|
|
42
|
+
server, and calls the Todo0 MCP server with the resulting access token.
|
|
43
|
+
|
|
44
|
+
The XAA exchange happens entirely on the gateway's outbound side. See
|
|
45
|
+
[the overview](./overview.mdx#how-the-flow-works) for the full sequence.
|
|
46
|
+
|
|
47
|
+
## Prerequisites
|
|
48
|
+
|
|
49
|
+
- A [Zuplo account](https://portal.zuplo.com)
|
|
50
|
+
- A free account on [xaa.dev](https://xaa.dev)
|
|
51
|
+
- [MCP Jam](https://www.mcpjam.com) — a browser-based MCP client used to drive
|
|
52
|
+
the flow. Any MCP client that supports OAuth works.
|
|
53
|
+
|
|
54
|
+
## Steps
|
|
55
|
+
|
|
56
|
+
<Stepper>
|
|
57
|
+
|
|
58
|
+
1. **Create a Zuplo project.**
|
|
59
|
+
|
|
60
|
+
In the [Zuplo Portal](https://portal.zuplo.com/+/account/projects), select
|
|
61
|
+
**New Project** and create an empty **API Gateway** project. Name it
|
|
62
|
+
`xaa-quick-start`.
|
|
63
|
+
|
|
64
|
+
On the project **Overview** page, copy the deployment URL shown at the top
|
|
65
|
+
(for example, `https://xaa-quick-start-main-abc1234.d2.zuplo.dev`). This is
|
|
66
|
+
your gateway's public origin — you'll need it for the next two steps.
|
|
67
|
+
|
|
68
|
+
:::note
|
|
69
|
+
|
|
70
|
+
Throughout this guide, replace `https://your-gateway.zuplo.dev` with your
|
|
71
|
+
project's actual deployment URL.
|
|
72
|
+
|
|
73
|
+
:::
|
|
74
|
+
|
|
75
|
+
2. **Register a requesting app on xaa.dev.**
|
|
76
|
+
|
|
77
|
+
Sign in to [xaa.dev](https://xaa.dev) and open
|
|
78
|
+
[the developer registration page](https://xaa.dev/developer/register). Enter
|
|
79
|
+
your email, then select **Register New App** and fill in:
|
|
80
|
+
- **Application Name** — anything, for example `xaa-quick-start gateway`.
|
|
81
|
+
- **Redirect URIs** — your gateway's OAuth callback:
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
https://your-gateway.zuplo.dev/__zuplo/oauth/callback
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Under **Resource Connections**, select **Todo0 MCP Server**, keep both scopes
|
|
88
|
+
(`todos.read` and `mcp.access`) checked, and select **Add Connection**. Then
|
|
89
|
+
select **Register App**.
|
|
90
|
+
|
|
91
|
+
<BrowserScreenshot url="https://xaa.dev/developer/register">
|
|
92
|
+
|
|
93
|
+

|
|
94
|
+
|
|
95
|
+
</BrowserScreenshot>
|
|
96
|
+
|
|
97
|
+
Registration shows four values. Copy all of them — the secrets are shown only
|
|
98
|
+
once:
|
|
99
|
+
|
|
100
|
+
| xaa.dev value | Used for |
|
|
101
|
+
| ---------------------- | ------------------------------- |
|
|
102
|
+
| Client ID | `XAA_CLIENT_ID` |
|
|
103
|
+
| Client Secret | `XAA_CLIENT_SECRET` |
|
|
104
|
+
| Resource Client ID | `XAA_RESOURCE_AS_CLIENT_ID` |
|
|
105
|
+
| Resource Client Secret | `XAA_RESOURCE_AS_CLIENT_SECRET` |
|
|
106
|
+
|
|
107
|
+
:::tip
|
|
108
|
+
|
|
109
|
+
The playground identity provider (IdenX) accepts any email with no password,
|
|
110
|
+
so you can sign in as any test user when you drive the flow.
|
|
111
|
+
|
|
112
|
+
:::
|
|
113
|
+
|
|
114
|
+
3. **Add the gateway configuration.**
|
|
115
|
+
|
|
116
|
+
Open the project's code editor (the **Code** tab). The gateway needs three
|
|
117
|
+
files.
|
|
118
|
+
|
|
119
|
+
First, define the two policies. Open `config/policies.json` (use the raw
|
|
120
|
+
`policies.json` tab, not the visual Policy List) and replace its contents:
|
|
121
|
+
|
|
122
|
+
```json title="config/policies.json"
|
|
123
|
+
{
|
|
124
|
+
"policies": [
|
|
125
|
+
{
|
|
126
|
+
"name": "xaa-inbound",
|
|
127
|
+
"policyType": "mcp-oauth-inbound",
|
|
128
|
+
"handler": {
|
|
129
|
+
"module": "$import(@zuplo/runtime/mcp-gateway)",
|
|
130
|
+
"export": "McpOAuthInboundPolicy",
|
|
131
|
+
"options": {
|
|
132
|
+
"oidc": {
|
|
133
|
+
"issuer": "https://idp.xaa.dev",
|
|
134
|
+
"jwksUrl": "https://idp.xaa.dev/jwks",
|
|
135
|
+
"audience": "$env(GATEWAY_AUDIENCE)"
|
|
136
|
+
},
|
|
137
|
+
"browserLogin": {
|
|
138
|
+
"url": "https://idp.xaa.dev/authorize",
|
|
139
|
+
"tokenUrl": "https://idp.xaa.dev/token",
|
|
140
|
+
"clientId": "$env(XAA_CLIENT_ID)",
|
|
141
|
+
"clientSecret": "$env(XAA_CLIENT_SECRET)",
|
|
142
|
+
"scope": "openid profile email",
|
|
143
|
+
"audience": "$env(GATEWAY_AUDIENCE)",
|
|
144
|
+
"pkce": "S256"
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"name": "id-jag-upstream",
|
|
151
|
+
"policyType": "mcp-token-exchange-inbound",
|
|
152
|
+
"handler": {
|
|
153
|
+
"module": "$import(@zuplo/runtime/mcp-gateway)",
|
|
154
|
+
"export": "McpTokenExchangeInboundPolicy",
|
|
155
|
+
"options": {
|
|
156
|
+
"id": "id-jag-upstream",
|
|
157
|
+
"displayName": "Todo0",
|
|
158
|
+
"summary": "xaa.dev Todo0 MCP server, reached via Cross App Access (ID-JAG).",
|
|
159
|
+
"authMode": "id-jag",
|
|
160
|
+
"idJag": {
|
|
161
|
+
"scopes": ["todos.read", "mcp.access"],
|
|
162
|
+
"idp": {
|
|
163
|
+
"tokenUrl": "https://idp.xaa.dev/token",
|
|
164
|
+
"clientAuth": {
|
|
165
|
+
"method": "client_secret_post",
|
|
166
|
+
"clientId": "$env(XAA_CLIENT_ID)",
|
|
167
|
+
"clientSecret": "$env(XAA_CLIENT_SECRET)"
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
"resourceAs": {
|
|
171
|
+
"tokenUrl": "https://auth.resource.xaa.dev/token",
|
|
172
|
+
"audience": "https://auth.resource.xaa.dev",
|
|
173
|
+
"resource": "https://mcp.xaa.dev/mcp",
|
|
174
|
+
"clientAuth": {
|
|
175
|
+
"method": "client_secret_post",
|
|
176
|
+
"clientId": "$env(XAA_RESOURCE_AS_CLIENT_ID)",
|
|
177
|
+
"clientSecret": "$env(XAA_RESOURCE_AS_CLIENT_SECRET)"
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
]
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The IdP, resource authorization server, and upstream endpoints are wired to
|
|
189
|
+
the playground here. Only the credentials and the gateway audience are
|
|
190
|
+
environment-driven, so you can plug in your own xaa.dev app. For every
|
|
191
|
+
option, see the [configuration reference](./policy-reference.mdx).
|
|
192
|
+
|
|
193
|
+
Next, add the route that exposes the gateway. Open `config/routes.oas.json`
|
|
194
|
+
(the raw `routes.oas.json` tab) and replace its contents:
|
|
195
|
+
|
|
196
|
+
```json title="config/routes.oas.json"
|
|
197
|
+
{
|
|
198
|
+
"openapi": "3.1.0",
|
|
199
|
+
"info": {
|
|
200
|
+
"version": "1.0.0",
|
|
201
|
+
"title": "XAA MCP Gateway",
|
|
202
|
+
"description": "MCP gateway that bridges to the xaa.dev Todo0 MCP server via Cross App Access (ID-JAG)."
|
|
203
|
+
},
|
|
204
|
+
"paths": {
|
|
205
|
+
"/mcp/todo0": {
|
|
206
|
+
"post": {
|
|
207
|
+
"operationId": "todo0Bridge",
|
|
208
|
+
"summary": "Bridge to the xaa.dev Todo0 MCP server",
|
|
209
|
+
"x-zuplo-route": {
|
|
210
|
+
"corsPolicy": "none",
|
|
211
|
+
"handler": {
|
|
212
|
+
"module": "$import(@zuplo/runtime/mcp-gateway)",
|
|
213
|
+
"export": "McpProxyHandler",
|
|
214
|
+
"options": {
|
|
215
|
+
"rewritePattern": "https://mcp.xaa.dev/mcp"
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
"policies": {
|
|
219
|
+
"inbound": ["xaa-inbound", "id-jag-upstream"]
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
The `McpProxyHandler` forwards the request to the upstream Todo0 server, and
|
|
229
|
+
both policies run inbound — first the MCP OAuth check, then the XAA exchange.
|
|
230
|
+
|
|
231
|
+
Finally, register the MCP Gateway plugin. In the file tree, right-click the
|
|
232
|
+
`modules` folder, select **New Runtime Extension**, and replace the generated
|
|
233
|
+
file's contents:
|
|
234
|
+
|
|
235
|
+
```typescript title="modules/zuplo.runtime.ts"
|
|
236
|
+
import { RuntimeExtensions } from "@zuplo/runtime";
|
|
237
|
+
import { McpGatewayPlugin } from "@zuplo/runtime/mcp-gateway";
|
|
238
|
+
|
|
239
|
+
// Registers the MCP Gateway, which adds the OAuth and upstream-connection
|
|
240
|
+
// routes used to expose and secure MCP servers through your gateway.
|
|
241
|
+
// Docs: https://zuplo.com/docs/mcp-server/introduction
|
|
242
|
+
export function runtimeInit(runtime: RuntimeExtensions) {
|
|
243
|
+
runtime.addPlugin(new McpGatewayPlugin());
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Save each file. The build fails until the environment variables exist —
|
|
248
|
+
that's the next step.
|
|
249
|
+
|
|
250
|
+
4. **Set the environment variables.**
|
|
251
|
+
|
|
252
|
+
Open **Settings →
|
|
253
|
+
[Environment Variables](https://portal.zuplo.com/+/account/project/settings/environment-variables)**
|
|
254
|
+
and add each variable below with **Add variable**. Mark the two `*_SECRET`
|
|
255
|
+
values as **Secret** so they're encrypted and hidden after saving. Leave all
|
|
256
|
+
three environments (Production, Preview, Development) selected.
|
|
257
|
+
|
|
258
|
+
| Variable | Value | Secret |
|
|
259
|
+
| ------------------------------- | ----------------------------------------------------------------------------- | ------ |
|
|
260
|
+
| `GATEWAY_AUDIENCE` | Your gateway's deployment URL (for example, `https://your-gateway.zuplo.dev`) | No |
|
|
261
|
+
| `XAA_CLIENT_ID` | Client ID from the xaa.dev app | No |
|
|
262
|
+
| `XAA_CLIENT_SECRET` | Client Secret from the xaa.dev app | Yes |
|
|
263
|
+
| `XAA_RESOURCE_AS_CLIENT_ID` | Resource Client ID from the Todo0 connection | No |
|
|
264
|
+
| `XAA_RESOURCE_AS_CLIENT_SECRET` | Resource Client Secret from the Todo0 connection | Yes |
|
|
265
|
+
|
|
266
|
+
Saving an environment variable triggers a new deployment. Once it finishes,
|
|
267
|
+
the gateway is live.
|
|
268
|
+
|
|
269
|
+
5. **Connect with MCP Jam and list the todos.**
|
|
270
|
+
|
|
271
|
+
Open the [MCP Jam web inspector](https://www.mcpjam.com), go to **Connect**,
|
|
272
|
+
and select **Add Server**:
|
|
273
|
+
- **Server Name** — `xaa-quick-start`
|
|
274
|
+
- **Connection Type** — `HTTPS`, with the route URL
|
|
275
|
+
`https://your-gateway.zuplo.dev/mcp/todo0`
|
|
276
|
+
- **Authentication** — `OAuth`
|
|
277
|
+
|
|
278
|
+
<ModalScreenshot>
|
|
279
|
+
|
|
280
|
+

|
|
281
|
+
|
|
282
|
+
</ModalScreenshot>
|
|
283
|
+
|
|
284
|
+
Select **Add Server**. MCP Jam starts the OAuth flow and redirects you to
|
|
285
|
+
sign in. Sign in at the IdenX screen (any email, no password) and approve the
|
|
286
|
+
consent screen. MCP Jam returns to the inspector and the server shows as
|
|
287
|
+
**Connected**.
|
|
288
|
+
|
|
289
|
+
<Framed>
|
|
290
|
+
|
|
291
|
+

|
|
292
|
+
|
|
293
|
+
</Framed>
|
|
294
|
+
|
|
295
|
+
Open the **Resources** panel and select **List all todos**. The todos come
|
|
296
|
+
back as JSON — the agent never touched the XAA protocol. Behind the scenes
|
|
297
|
+
the gateway minted an ID-JAG from the playground IdP, redeemed it at the
|
|
298
|
+
resource authorization server, and called the Todo0 MCP server with the
|
|
299
|
+
resulting access token.
|
|
300
|
+
|
|
301
|
+
<Framed>
|
|
302
|
+
|
|
303
|
+

|
|
304
|
+
|
|
305
|
+
</Framed>
|
|
306
|
+
|
|
307
|
+
</Stepper>
|
|
308
|
+
|
|
309
|
+
## Verify from the command line (optional)
|
|
310
|
+
|
|
311
|
+
To confirm the gateway is deployed and secured without a client, check its OAuth
|
|
312
|
+
metadata and the protected route:
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
# Protected-resource metadata is published for the route (expect 200)
|
|
316
|
+
curl -s https://your-gateway.zuplo.dev/.well-known/oauth-protected-resource/mcp/todo0
|
|
317
|
+
|
|
318
|
+
# The MCP route rejects unauthenticated calls (expect 401)
|
|
319
|
+
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
|
|
320
|
+
https://your-gateway.zuplo.dev/mcp/todo0 \
|
|
321
|
+
-H "Content-Type: application/json" \
|
|
322
|
+
-H "Accept: application/json, text/event-stream" \
|
|
323
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1.0.0"}}}'
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
## Next steps
|
|
327
|
+
|
|
328
|
+
- [Configuration reference](./policy-reference.mdx) — the full `idJag` option
|
|
329
|
+
set.
|
|
330
|
+
- [Overview](./overview.mdx) — the protocol and the gateway's role explained.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zuplo",
|
|
3
|
-
"version": "6.71.
|
|
3
|
+
"version": "6.71.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The programmable API Gateway",
|
|
6
6
|
"author": "Zuplo, Inc.",
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
"zuplo": "zuplo.js"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@zuplo/cli": "6.71.
|
|
23
|
-
"@zuplo/core": "6.71.
|
|
24
|
-
"@zuplo/runtime": "6.71.
|
|
22
|
+
"@zuplo/cli": "6.71.12",
|
|
23
|
+
"@zuplo/core": "6.71.12",
|
|
24
|
+
"@zuplo/runtime": "6.71.12",
|
|
25
25
|
"@zuplo/test": "1.4.0"
|
|
26
26
|
}
|
|
27
27
|
}
|