agentic-community / agentic-community/mcp-gateway-registry
feat: Support External User Access Tokens (Service-to-Service on Behalf of Users)
- Vorherrschende Sprache
- Python
- Sterne
- 911
- Forks
- 234
- Ø Merge
- 1 T. 11 Std.
- Gemergte PRs (30 T.)
- 62
Beschreibung
## Problem Statement
mcp-gateway-registry's auth server currently supports three token types on its `/validate` endpoint:
1. **Self-signed session tokens** (HS256) -- issued by the auth server itself after a browser-based OIDC login. Groups are extracted from the IdP's ID token and baked into the self-signed token at login time.
2. **M2M tokens** (client credentials) -- no user context, no groups in the token. Groups can be enriched from MongoDB via the `idp_m2m_clients` collection.
3. **Static tokens** -- simple bearer tokens validated via `hmac.compare_digest`, with hard-coded identity and permissions:
- **Registry API static token** (`REGISTRY_API_TOKEN`): hard-coded `groups: ["mcp-registry-admin"]` with unrestricted read/execute scopes.
- **Federation static token** (`FEDERATION_STATIC_TOKEN`): hard-coded `groups: []` with federation-only scopes.
There's currently no support for this scenario: an external application that has its own IdP integration and wants to call mcp-gateway-registry APIs **on behalf of a user**, passing the user's access token.
### Concrete example
Application "Frontend App" has its own Okta application and authenticates users through its own UI. When it needs to call mcp-gateway-registry's API on behalf of the logged-in user, it passes the user's Okta access token in the `Authorization: Bearer` header. Today, this fails because:
1. **Audience mismatch** -- The token's `aud`/`cid` claim won't match `OktaProvider.client_id` or `m2m_client_id`, because the token was issued for the external app, not the registry.
2. **Issuer mismatch** (conditional) -- If the external app uses a different authorization server, the token's `iss` won't match `OktaProvider.issuer`.
3. **No groups in the access token** -- When using Okta's org authorization server, the `groups` claim is only present in ID tokens, not access tokens. The token arrives with an empty or missing `groups` claim.
4. **No groups resolution path** -- Unlike the browser flow (which reads groups from the ID token) or the M2M flow (which enriches from MongoDB), there is no mechanism to resolve groups for an external user access token. `should_enrich_groups()` in `mongodb_groups_enrichment.py` was designed for M2M clients, not user tokens.
Without groups, `map_groups_to_scopes()` returns no scopes, and the user is effectively denied access.
### Why this matters
As mcp-gateway-registry adoption grows, more applications will need to integrate via API on behalf of their users rather than redirecting users to the registry's own UI. This is the pattern for platform services -- the registry acts as a backend service that frontends can consume with delegated user identity. Without this, every consuming application must either redirect users to the registry's own login page, or fall back to a shared M2M credential that erases per-user identity.
---
## Proposed Solutions
I'm proposing 2 potential solutions for brainstorming:
### Solution A: Userinfo-Based Group Enrichment
After validating the external user's access token, the auth server calls the IdP's `/userinfo` endpoint with that token to retrieve the user's groups.
**How it works:**
```
External App mcp-gateway-registry auth server
| |
| Authorization: Bearer |
|----------------------------------------------->|
| |
| 1. Validate JWT signature against JWKS
| 2. Token's cid in trusted_client_ids? Yes
| 3. Token has uid but no groups
| 4. Call IdP /userinfo with the token
| 5. Extract groups from userinfo response
| 6. map_groups_to_scopes() as normal
| 7. Authorize
| |
|<-----------------------------------------------|
| 200 OK (with X-Groups, X-Scopes) |
```
**What needs to change:**
- **Trusted client ID allowlist** -- new environment variable (e.g., `OKTA_TRUSTED_CLIENT_IDS`) so the auth server accepts tokens from configured external app client IDs without failing on audience mismatch.
- **Trusted issuer allowlist** (if external app uses a different authorization server) -- new environment variable (e.g., `OKTA_TRUSTED_ISSUERS`) with per-issuer JWKS resolution.
- **Userinfo groups enrichment** -- when a validated user token (has `uid` claim) has empty `groups`, call the IdP's `/userinfo` endpoint with the access token and extract groups. Cache the result per user with a bounded TTL (e.g., 5 minutes).
- **No changes to downstream logic** -- `map_groups_to_scopes()`, scopes configuration, and all existing token flows remain untouched.
**Trade-offs:**
| Pros | Cons |
|---|---|
| Minimal changes on the external app side -- just pass the access token | Runtime dependency on IdP `/userinfo` for every unique token (mitigated by caching) |
| Groups are always fresh (refreshed on cache miss) | The token's `aud` is the external app's, not the registry's -- weaker from an OAuth 2.0 audience perspective |
| No new token types or flows | Subject to IdP rate limits on cache misses |
| OIDC-standard approach (Section 5.3) | No delegation visibility unless `cid` is explicitly logged |
#### Ref
/userinfo endpoint is part of the OIDC spec, so I'm assuming this is supported in most IdPs
- OIDC ref: https://openid.net/specs/openid-connect-core-1_0-final.html#UserInfo
- Okta ref: https://developer.okta.com/docs/api/openapi/okta-oauth/oauth/orgas/userinfo
- Auth0 ref: https://auth0.com/docs/api/authentication/user-profile/get-user-info#endpoint
#### Variation: Userinfo Enrichment in Provider's `validate_token()`
Rather than enriching groups in the `/validate` endpoint (server-level), the userinfo call could be integrated directly into the auth provider's `validate_token()` method. When `validate_token()` returns a valid result with `uid` but empty `groups`, it would call `/userinfo` before returning.
This is functionally identical to Solution A but places the enrichment inside the provider rather than in the server layer. It may be a cleaner abstraction if multiple providers need userinfo enrichment, but it couples the IdP network call more tightly to token validation.
---
### Solution B: Token Exchange (Extend Existing Programmatic Token Infrastructure)
The external app exchanges its ID token + access token for a **self-signed registry token** via a new endpoint. This mirrors the browser login flow programmatically.
**Key insight:** The registry already has infrastructure for minting self-signed tokens. The UI's "Generate JWT Token" feature uses `POST /api/tokens/generate` (which calls `POST /internal/tokens` internally) to produce HS256 self-signed JWTs with groups and scopes baked in. These tokens are validated by `_validate_self_signed_token()`. The token exchange endpoint would produce the **exact same token format** -- it is a new entry point into the existing self-signed token ecosystem, not a new token type.
**Why the existing endpoints can't be used directly:**
| Endpoint | Why it doesn't work |
|---|---|
| `POST /api/tokens/generate` | Requires the caller to already be authenticated (session cookie or valid Bearer token). The external app's user has no session on the registry -- chicken-and-egg problem. |
| `POST /internal/tokens` | Internal service-to-service endpoint. Trusts `user_context` without verification. Exposing it externally would be a security hole. |
The token exchange endpoint bridges this gap: it handles authentication (validating external Okta tokens) and then feeds the result into the same minting logic.
**How it works:**
```
External App mcp-gateway-registry auth server
| |
| POST /oauth2/token-exchange |
| { id_token: "eyJ...", |
| access_token: "eyJ...", |
| provider: "okta" } |
|----------------------------------------------->|
| |
| 1. Validate access token (sig, expiry)
| 2. Check cid in trusted_client_ids
| 3. Validate ID token (sig against JWKS)
| 4. Cross-check: sub matches in both
| 5. Extract groups from ID token
| 6. map_groups_to_scopes()
| 7. Mint self-signed HS256 token
| (same format as /internal/tokens)
| |
|<-----------------------------------------------|
| { access_token: "eyJ..(self-signed)", |
| expires_in: 28800 } |
| |
| Subsequent API calls: |
| Authorization: Bearer |
|----------------------------------------------->|
| _validate_self_signed_token()
| (existing path, no changes)
|<-----------------------------------------------|
```
All three self-signed token creation paths converge on the same format and validation:
```
Browser login ──────────> OAuth callback ──────────────────────> _validate_self_signed_token()
UI "Generate Token" ────> POST /api/tokens/generate ───────────┐
(already logged in) → POST /internal/tokens ├> _validate_self_signed_token()
│
External app ───────────> POST /oauth2/token-exchange (NEW) ───┘
(has ID + access tokens)
```
**What needs to change:**
- **Trusted client ID allowlist** -- same as Solution A.
- **New endpoint `POST /oauth2/token-exchange`** -- validates both external tokens, extracts groups from the ID token, maps groups to scopes, and mints a self-signed HS256 JWT using the same claims structure as `POST /internal/tokens`.
- **No changes to `/validate` or `_validate_self_signed_token()`** -- the minted token flows through the existing self-signed validation path.
**Trade-offs:**
| Pros | Cons |
|---|---|
| No runtime IdP dependency -- after exchange, all API calls are validated locally (HS256) | External app must implement: call exchange endpoint, cache token, handle expiry/re-exchange |
| Proper `aud: "mcp-registry"` on the self-signed token (correct OAuth 2.0 audience) | External app must hold the ID token (contains PII) |
| Reuses existing self-signed token format and `_validate_self_signed_token()` -- no new validation logic | Groups are frozen at exchange time until token expires |
| Delegation audit trail via `auth_method: "token_exchange"` and `source_client_id` in the token | New endpoint is additional attack surface (must be rate-limited) |
| No IdP rate limit exposure at runtime | |
## Scope of Changes
### What changes
- New configuration: trusted external client IDs (and optionally trusted issuers for cross-auth-server scenarios)
- Solution A: groups enrichment via `/userinfo` when a validated user token has no groups
- Solution B: new `POST /oauth2/token-exchange` endpoint that validates external tokens and mints self-signed JWTs
### What does NOT change
- **Browser login flow** -- ID token groups extraction, session cookie, self-signed tokens. Completely untouched.
- **M2M flow** -- client credentials + MongoDB enrichment. Unchanged.
- **Static token flow** -- registry API and federation static tokens. Unchanged.
- **`_validate_self_signed_token()`** -- the self-signed token validation path. Unchanged (Solution B produces tokens that flow through this existing path).
- **`map_groups_to_scopes()`** -- groups-to-scopes mapping logic reused as-is.
- **`scopes.yml` / `group_mappings`** -- no changes needed. The groups from userinfo or ID tokens are the same IdP groups that would appear in a browser login.
---
## Prerequisites
1. **Same IdP tenant** -- the external app and the registry must share the same IdP tenant for groups to be consistent.
2. **Groups claim configured** -- the external app's IdP configuration must include the `groups` claim so that `/userinfo` (Solution A) or the ID token (Solution B) returns groups.
3. **Group mappings** -- the registry's scopes configuration must include mappings for the groups that external app users belong to.
Beitragsleitfaden
Bewertung
Dieses Issue wurde noch nicht bewertet.