|
| 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