Skip to content

Commit c1fa430

Browse files
loafoeclaude
andcommitted
DEP for Enterprise-Managed Authorization for MCP (Resource Authorization Server role)
Proposes the Resource Authorization Server side of the MCP EMA flow: accepting an ID-JAG via the JWT Bearer grant (RFC 7523) and issuing a resource-bound access token. Complements DEP 4600 (ID-JAG issuance). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Andy Lo-A-Foe <andy.loafoe@gmail.com>
1 parent db855ad commit c1fa430

1 file changed

Lines changed: 344 additions & 0 deletions

File tree

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
# Dex Enhancement Proposal (DEP) - 2026-06-19 - Enterprise-Managed Authorization for MCP (Resource Authorization Server role)
2+
3+
## Table of Contents
4+
5+
- [Summary](#summary)
6+
- [Context](#context)
7+
- [Motivation](#motivation)
8+
- [Goals/Pain](#goalspain)
9+
- [Non-goals](#non-goals)
10+
- [Proposal](#proposal)
11+
- [User Experience](#user-experience)
12+
- [Implementation Details/Notes/Constraints](#implementation-detailsnotesconstraints)
13+
- [Observability](#observability)
14+
- [Risks and Mitigations](#risks-and-mitigations)
15+
- [Alternatives](#alternatives)
16+
- [Future Improvements](#future-improvements)
17+
18+
## Summary
19+
20+
The Model Context Protocol (MCP) defines an
21+
[Enterprise-Managed Authorization (EMA)][ema-spec] extension that lets an
22+
organization control MCP server access centrally through its identity provider.
23+
EMA is a profile of the
24+
[Identity Assertion JWT Authorization Grant (ID-JAG)][id-jag] draft: an MCP
25+
client obtains an ID-JAG from the enterprise IdP and redeems it for an access
26+
token at the MCP server's **Resource Authorization Server**.
27+
28+
[DEP 4600][dep4600] adds the IdP side of this exchange — Dex *issuing* ID-JAGs
29+
via Token Exchange. This DEP proposes the complementary **Resource
30+
Authorization Server** role: Dex *accepting* an ID-JAG (minted by a trusted
31+
enterprise IdP) via the JWT Bearer grant ([RFC 7523]) and issuing an access
32+
token that is audience-restricted to the MCP server. Together the two DEPs let
33+
Dex satisfy every server-side role in the EMA flow.
34+
35+
[ema-spec]: https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization
36+
[id-jag]: https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/
37+
[dep4600]: ./id-jag-2026-03-02%234600.md
38+
[RFC 7523]: https://datatracker.ietf.org/doc/html/rfc7523
39+
[RFC 8693]: https://datatracker.ietf.org/doc/html/rfc8693
40+
[RFC 9728]: https://datatracker.ietf.org/doc/html/rfc9728
41+
42+
## Context
43+
44+
- [DEP 4600 - ID-JAG issuance][dep4600] (PR
45+
[#4611](https://github.com/dexidp/dex/pull/4611)) implements the IdP side:
46+
Token Exchange with `requested_token_type=urn:ietf:params:oauth:token-type:id-jag`,
47+
the `oauth-id-jag+jwt` JWT, and static per-client `idJAGPolicies`. **This DEP
48+
builds directly on it and assumes it has merged.**
49+
- [DEP #2812 - RFC 8693 Token Exchange](https://github.com/dexidp/dex/pull/2812)
50+
established the Token Exchange foundation.
51+
- The [EMA specification][ema-spec] is *Stable* in the MCP `ext-auth`
52+
repository and profiles the IETF ID-JAG draft (`-04`).
53+
54+
The roles in the EMA flow, using the spec's terminology:
55+
56+
| EMA role | Who | Dex support |
57+
|---|---|---|
58+
| Client | MCP Client | out of scope (client-side) |
59+
| IdP Authorization Server | enterprise IdP | DEP 4600 (Dex can play it) |
60+
| **Resource Authorization Server** | issues MCP access tokens | **this DEP** |
61+
| Resource Server | MCP Server | out of scope (validates tokens) |
62+
63+
The end-to-end flow (EMA §2), with the part this DEP adds in **bold**:
64+
65+
```
66+
MCP Client --(SSO / OIDC)--------> Enterprise IdP : login -> ID Token
67+
MCP Client --(RFC 8693 exchange)-> Enterprise IdP : ID Token -> ID-JAG
68+
MCP Client --(RFC 7523 jwt-bearer)->Dex (Resource AS): **ID-JAG -> access token**
69+
MCP Client --(Bearer token)------> MCP Server : call MCP API
70+
```
71+
72+
## Motivation
73+
74+
### Goals/Pain
75+
76+
In enterprise MCP deployments, the default per-user/per-server authorization
77+
model creates friction and security gaps: each employee must individually
78+
authorize every MCP server, security teams cannot enforce consistent policy,
79+
and on/offboarding means touching each service. EMA moves the access decision
80+
to the enterprise IdP. For Dex to participate as the authorization server in
81+
front of MCP servers, it must accept the ID-JAG the IdP mints.
82+
83+
Specific goals:
84+
85+
- Accept an ID-JAG presented via the JWT Bearer grant
86+
(`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, RFC 7523) at the
87+
existing `/token` endpoint.
88+
- Validate the ID-JAG against a configured set of **trusted issuers**:
89+
signature against the issuer's JWKS, the `oauth-id-jag+jwt` header type, the
90+
issuer, the audience (this Dex's issuer per EMA §4), expiry, and an optional
91+
`client_id` allow-list.
92+
- Issue an access token that is **audience-restricted to the MCP server**
93+
identified by the ID-JAG `resource` claim (EMA §5.1).
94+
- Advertise support so MCP clients use the brokered flow: add the
95+
`urn:ietf:params:oauth:grant-profile:id-jag` profile to
96+
`authorization_grant_profiles_supported` in discovery metadata (EMA §6).
97+
- Be fully opt-in and off by default, with no change to existing deployments.
98+
99+
### Non-goals
100+
101+
- **ID-JAG issuance (the IdP role).** Covered by [DEP 4600][dep4600].
102+
- **SAML assertion → refresh-token exchange** (EMA §4 SAML branch). Only the
103+
OIDC ID-token path is in scope, consistent with DEP 4600 deferring SAML.
104+
- **A policy / conditional-access engine** (MFA step-up, device posture, group
105+
evaluation at redemption time). The IdP makes the access decision when it
106+
mints the ID-JAG; Dex as Resource AS validates that decision and binds the
107+
audience. Trust configuration is a static allow-list.
108+
- **Client ID Metadata Documents** and `private_key_jwt` client auth (EMA §5).
109+
Clients authenticate with their registered Dex credentials. CIMD is deferred.
110+
- **RFC 9728 Protected Resource Metadata endpoint.** Useful for client
111+
discovery of the MCP server → Dex relationship, but orthogonal to the grant
112+
itself. Listed under Future Improvements.
113+
- **MCP transport concerns** (`initialize` capability negotiation) — entirely
114+
the MCP client/server layer.
115+
116+
## Proposal
117+
118+
### User Experience
119+
120+
A Dex operator enables the Resource Authorization Server role by configuring a
121+
new `oauth2.enterpriseManagedAuthorization` block and adding the JWT Bearer
122+
grant to `oauth2.grantTypes`. When not configured (the default), the grant is
123+
not registered and behavior is unchanged.
124+
125+
```yaml
126+
oauth2:
127+
grantTypes:
128+
# ...existing grants...
129+
- urn:ietf:params:oauth:grant-type:jwt-bearer # enables this role
130+
enterpriseManagedAuthorization:
131+
enabled: true
132+
# When true, the verified email from the ID-JAG is carried into the
133+
# issued access token to support account linking. Default false.
134+
accountLinkingByEmail: false
135+
# Enterprise IdPs whose ID-JAGs Dex will accept.
136+
trustedIssuers:
137+
- issuer: "https://acme.okta.example"
138+
jwksURL: "https://acme.okta.example/oauth2/v1/keys"
139+
# The ID-JAG "aud" must equal this. Defaults to Dex's own issuer.
140+
expectedAudience: "https://mcp-as.example/"
141+
# Optional allow-list of accepted ID-JAG "client_id" values.
142+
allowedClientIDs: ["f53f191f9311af35"]
143+
```
144+
145+
> **Note on `grantTypes`:** Dex computes the supported grant set as the
146+
> intersection of implemented grants with `oauth2.grantTypes` (or its injected
147+
> default when unset). The injected default does not include `jwt-bearer`, so
148+
> operators must list `grantTypes` explicitly to enable this role. Enabling
149+
> `enterpriseManagedAuthorization` alone is not sufficient — this is called out
150+
> in the docs and logged at startup.
151+
152+
The end-to-end exchange, as the MCP client sees it:
153+
154+
```mermaid
155+
sequenceDiagram
156+
participant C as MCP Client
157+
participant IdP as Enterprise IdP
158+
participant Dex as Dex (Resource AS)
159+
participant RS as MCP Server
160+
161+
C->>IdP: Token Exchange (id_token -> ID-JAG)<br/>audience=Dex issuer, resource=MCP server
162+
IdP-->>C: ID-JAG (oauth-id-jag+jwt)
163+
C->>Dex: POST /token<br/>grant_type=jwt-bearer<br/>assertion=ID-JAG
164+
Note over Dex: Validate signature (issuer JWKS),<br/>typ, iss, aud, exp, client_id.<br/>Bind audience to resource claim.
165+
Dex-->>C: access_token (aud = resource), token_type=bearer
166+
C->>RS: MCP API call with access_token
167+
```
168+
169+
The access token request (EMA §5):
170+
171+
```
172+
POST /token HTTP/1.1
173+
Host: mcp-as.example
174+
Authorization: Basic <client credentials>
175+
Content-Type: application/x-www-form-urlencoded
176+
177+
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
178+
&assertion=eyJ0eXAiOiJvYXV0aC1pZC1qYWcrand0Ii...
179+
```
180+
181+
The response is a standard OAuth 2.0 token response whose access token is
182+
audience-restricted to the `resource` claim from the ID-JAG:
183+
184+
```
185+
HTTP/1.1 200 OK
186+
Content-Type: application/json
187+
Cache-Control: no-store
188+
189+
{
190+
"access_token": "eyJhbGciOiJSUzI1NiIs...",
191+
"token_type": "bearer",
192+
"expires_in": 86400,
193+
"scope": "chat.read chat.history"
194+
}
195+
```
196+
197+
Discovery (`/.well-known/openid-configuration`) gains, when enabled:
198+
199+
```json
200+
"authorization_grant_profiles_supported": [
201+
"urn:ietf:params:oauth:grant-profile:id-jag"
202+
]
203+
```
204+
205+
### Implementation Details/Notes/Constraints
206+
207+
**Grant registration.** A new grant constant and dispatch case are added; the
208+
grant is registered in the supported set only when EMA is enabled:
209+
210+
```go
211+
const grantTypeJWTBearer = "urn:ietf:params:oauth:grant-type:jwt-bearer"
212+
213+
// in newServer, alongside the other allSupportedGrants entries:
214+
if c.EnterpriseManagedAuthorization.Enabled {
215+
allSupportedGrants[grantTypeJWTBearer] = true
216+
}
217+
218+
// in the /token switch:
219+
case grantTypeJWTBearer:
220+
s.withClientFromStorage(w, r, s.handleJWTBearerGrant)
221+
```
222+
223+
**Trusted-issuer verifiers.** At startup, one verifier is built per trusted
224+
issuer, backed by a remote JWKS (reusing `go-oidc`'s caching keyset, the same
225+
mechanism the OIDC connector uses). `expectedAudience` defaults to Dex's own
226+
issuer URL:
227+
228+
```go
229+
keySet := oidc.NewRemoteKeySet(ctx, ti.JWKSURL)
230+
verifier := oidc.NewVerifier(ti.Issuer, keySet, &oidc.Config{SkipClientIDCheck: true})
231+
```
232+
233+
**Validation.** `handleJWTBearerGrant` reads the `assertion`, selects the
234+
verifier by the (unverified) `iss`, then fully validates:
235+
236+
```go
237+
// 1. Select verifier by issuer; 2. verify signature/iss/exp via JWKS;
238+
// 3. enforce aud == expectedAudience (EMA §4);
239+
// 4. parse claims; 5. enforce optional client_id allow-list.
240+
idjag, err := s.verifyIDJAG(ctx, assertion)
241+
if err != nil { /* invalid_grant */ }
242+
243+
// EMA §5.1: the access token MUST be audience-restricted to the MCP server.
244+
// Without a resource claim there is no audience to bind, so reject.
245+
if idjag.Resource == "" { /* invalid_grant */ }
246+
```
247+
248+
The `typ: oauth-id-jag+jwt` header is checked, and the consumed claims are
249+
`sub`, `email`, `client_id`, `resource`, and `scope`.
250+
251+
**Resource-bound access token.** The issued token's audience is the `resource`
252+
claim, not the requesting client. This is the one behavioral difference from
253+
the existing token paths, so it lives in a dedicated builder rather than
254+
modifying `getAudience`/`newIDToken`, to guarantee no regression to
255+
authorization-code, refresh, or token-exchange flows:
256+
257+
```go
258+
func (s *Server) newResourceAccessToken(ctx, clientID, resource string, claims storage.Claims) (string, time.Time, error) {
259+
tok := idTokenClaims{
260+
Issuer: s.issuerURL.String(),
261+
Subject: claims.UserID, // the ID-JAG sub
262+
Audience: audience{resource}, // EMA §5.1
263+
AuthorizingParty: clientID,
264+
// exp/iat/jti...
265+
}
266+
// email carried only when accountLinkingByEmail populated it
267+
return s.signer.Sign(ctx, json.Marshal(tok))
268+
}
269+
```
270+
271+
**Account linking.** The ID-JAG `sub` is treated as opaque. When
272+
`accountLinkingByEmail` is set, the (verified) `email` claim is copied into the
273+
issued token so downstream MCP servers can link to a local account; otherwise
274+
only `sub` is propagated. This composes with [DEP 4600][dep4600], which carries
275+
a verified `email` claim into the ID-JAG it issues.
276+
277+
**Discovery.** `constructDiscovery` appends the id-jag grant profile to a new
278+
`authorization_grant_profiles_supported` field when EMA is enabled. DEP 4600
279+
separately adds `identity_chaining_requested_token_types_supported` for the IdP
280+
role; the two are complementary.
281+
282+
**Files touched** (all additive): a new `server/idjag.go` for the handler,
283+
verifier, and resource-token builder; small additions to `server/oauth2.go`
284+
(constants), `server/server.go` (config + grant registration), `server/handlers.go`
285+
(dispatch + discovery field), and `cmd/dex/config.go` / `cmd/dex/serve.go`
286+
(config plumbing).
287+
288+
### Observability
289+
290+
- Each redemption logs structured fields: requesting `client_id`, ID-JAG
291+
`iss`/`client_id`, `sub`, `resource`, and granted `scope` on success; the
292+
precise reason (`untrusted_issuer`, `audience_mismatch`, `missing_resource`,
293+
signature/expiry failure) on rejection.
294+
- ID-JAG validation failures are returned as `invalid_grant`; a missing
295+
`assertion` as `invalid_request`; a disabled/unregistered grant as
296+
`unsupported_grant_type` — matching RFC 6749 §5.2 and RFC 7523.
297+
298+
### Risks and Mitigations
299+
300+
- **Trust misconfiguration is an impersonation risk.** An over-broad
301+
`trustedIssuers` entry would let that issuer mint tokens for any user. Mitigations:
302+
default-deny (the role is off unless configured), a required and explicitly
303+
checked `expectedAudience`, an optional `client_id` allow-list, and logging of
304+
every accepted/rejected redemption with `iss`/`client_id`/`resource`.
305+
- **Replay.** A stolen ID-JAG is replayable within its lifetime. Mitigated by
306+
relying on the IdP's short ID-JAG expiry (validated here) and the audience
307+
restriction. Server-side `jti` caching is deferred (see DEP 4600, same stance).
308+
- **Audience-binding regressions.** The `resource`→`aud` behavior is isolated in
309+
a dedicated token builder and covered by tests asserting that
310+
authorization-code/refresh/token-exchange audiences are unchanged.
311+
- **Spec maturity.** EMA is *Stable* in MCP but profiles an IETF *draft* (`-04`);
312+
claim/field names may still shift. The entire feature is behind opt-in config,
313+
so churn is contained.
314+
- **Breaking changes: none.** Purely additive. With no `enterpriseManagedAuthorization`
315+
config, the grant is not registered, discovery is unchanged, and the new code
316+
paths are unreachable.
317+
318+
### Alternatives
319+
320+
- **External-IdP-only (no Dex IdP role).** Pairs naturally with DEP 4600: this
321+
DEP works with any conformant ID-JAG issuer (e.g. Okta Cross-App Access), and
322+
also composes with a second Dex running DEP 4600 — enabling a fully
323+
self-contained, testable deployment without a third-party IdP.
324+
- **Fold audience binding into the existing token builders** (`getAudience`).
325+
Rejected: it would entangle a security-sensitive change with every other grant
326+
and risk silent regressions; a dedicated builder is safer and clearer.
327+
- **CEL/OPA policy at redemption time.** Unnecessary: the access decision is made
328+
by the IdP when it issues the ID-JAG. Dex's job here is validation and audience
329+
binding, not re-deciding policy.
330+
- **Do nothing.** Dex can issue ID-JAGs (DEP 4600) but cannot sit in front of MCP
331+
servers in an EMA deployment, leaving the server-side story half-complete.
332+
333+
## Future Improvements
334+
335+
- **RFC 9728 Protected Resource Metadata** endpoint
336+
(`/.well-known/oauth-protected-resource`) so MCP clients can discover the
337+
MCP-server → Dex relationship and the resource identifier.
338+
- **RFC 8707 `resource` parameter** plumbing for non-EMA flows, enabling
339+
resource-bound tokens generally.
340+
- **Server-side `jti` tracking** to prevent ID-JAG replay within the validity
341+
window (shared with DEP 4600).
342+
- **Trusted-issuer config via storage/CEL** so issuers can be managed without a
343+
restart, building on the CEL infrastructure referenced by DEP 4600.
344+
- **SAML 2.0 assertion path** for IdPs that use SAML for SSO (EMA §4 SAML branch).

0 commit comments

Comments
 (0)