Repository navigation
feat: token exchange admin UI #7236 - #7265
lharrison13 wants to merge 4 commits into
Conversation
msureshkumar88
left a comment
There was a problem hiding this comment.
Thank you for the thorough work on #7236. The design follows the issue closely. The admin-only gate, the token_url validation, the separate discovery cache, and the invocation isolation are all sound. The PR is not ready to merge. Four items block it.
Verdict: request changes. Verified at head 6d75f0720 against base 86af292ee.
severity | path:line | problem | fix
blocking | mcpgateway/utils/token_exchange_audit.py:28, mcpgateway/services/gateway_service.py:1606,1632 | The PR does not change `token_exchange_audit.py`. `_request_discovery_token()` passes `purpose=` and `user_email=None`. `audit_token_exchange()` has no `purpose` parameter and types `user_email` as `str`. Every discovery token request raises `TypeError`. Registration, health checks, refresh, and "Fetch Tools" all fail for discovery gateways. | Add `purpose` (default `invocation`) and an optional `user_email`. Write `purpose` into the event.
blocking | tests/unit/... (22 failures) | The new tests assume the missing audit change. Failing files: `test_token_exchange_discovery.py` (11), `test_gateway_service_health_oauth.py` (2), `test_gateway_service.py` (4), `test_oauth_router.py` (1), `test_token_exchange_integration.py` (4). `make test` cannot pass. | Fix the audit module. Run `make test`. Tick the checklist items with evidence.
blocking | CHANGELOG.md | The PR body declares a breaking change: resource reads on token-exchange gateways now fail closed (`resource_service.py`). Before, the request went upstream without an `Authorization` header. `CHANGELOG.md` has no entry. | Add an entry under `[Unreleased]` > `Breaking Changes`. Name the resource-read change and the affected callers.
blocking | commits 5f6404f76, 6d75f0720 | Both commits carry a `Co-authored-by: Claude <noreply@anthropic.com>` trailer. Project rules keep AI attribution out of PRs and diffs. | @lharrison13: reword both commits, remove the `Co-authored-by` trailer, and keep `Signed-off-by`. Run `git rebase -i 86af292ee`, then `git push --force-with-lease`.
functionally-impacting | mcpgateway/admin.py:1099 | The edit form fills the secret field with the mask for token-exchange gateways. If an admin changes the grant to another type, the mask stays in the field. `_assemble_oauth_config_from_fields()` then encrypts the literal mask string and stores it as the client secret. I reproduced this: the stored secret decrypts to `*****`. The base code cleared the field, so the PR introduces this. | Keep the mask only when the stored and submitted grants are both `token-exchange`. Otherwise drop it. Add a regression test for the grant switch.
suggestion | mcpgateway/services/gateway_service.py:440-446 | `build_discovery_oauth_config()` inherits the parent `client_secret` when `discovery.token_url` or `discovery.client_id` is overridden. It also inherits the parent `scopes`. The docs state this, and an admin must set it. | Require an explicit `discovery.client_secret` when `token_url` is overridden. Consider not inheriting `scopes`.
suggestion | mcpgateway/services/gateway_service.py:5633 | A discovery token failure has no negative cache. An IdP outage retries per gateway on every health cycle. The user exchange already has `set_failure` and `is_failed`. | Add a short negative cache.
suggestion | mcpgateway/services/gateway_service_token_cache.py, token_exchange_cache.py | The PR adds a 152-line cache and moves `TokenExchangeCache` onto a shared base (about 230 changed lines). A discovery token needs one entry per gateway. | A small in-memory `{gateway_id: (token, expiry)}` with a lock avoids the refactor of the user-token cache. Add Redis only if workers must share the token.
suggestion | the PR | One PR carries User Story 1 (Admin UI grant) and User Story 2 (discovery credential). It adds 4642 lines. | Split into two PRs if the review load is high.
minor | tests/unit/mcpgateway/services/test_tool_service_coverage.py:11519 | `test_invocation_services_never_reference_discovery_resolver` checks source text with `inspect.getsource()`. A rename passes it. | Replace it with a behavioral test.
minor | mcpgateway/services/gateway_service.py | A cached discovery token is served until its TTL even after the upstream returns 401. Deactivation does not invalidate it. | Invalidate on a 401 and on deactivation.
Scope and checks:
- The change is both UI and backend. No Alembic migration is needed.
oauth_configis an existing JSON column, and no model changed. - No unrelated changes found.
.secrets.baselinechurn is ignored. - Prompt gets on OAuth gateways send no
Authorizationheader (build_gateway_auth_headers). This predates the PR and is out of scope. - The PR checklist is unchecked. Only the DCO check has run.
- Please also run
make coverage diff-coverandmake test-e2e, and attach the results.
I am happy to re-review after the audit fix and the green test run.
6d75f07 to
663fb59
Compare
Signed-off-by: Luke Harrison <Luke.Harrison1@ibm.com>
Signed-off-by: Luke Harrison <Luke.Harrison1@ibm.com>
Signed-off-by: Luke Harrison <Luke.Harrison1@ibm.com>
663fb59 to
290d68b
Compare
Add the purpose argument to audit_token_exchange() and accept a None user_email for discovery service tokens. Drop the masked client_secret placeholder when the edit form changes the grant type. Add a CHANGELOG entry for the fail-closed resource read on token-exchange gateways. Signed-off-by: Luke Harrison <Luke.Harrison1@ibm.com>
|
Validation results for
|
There was a problem hiding this comment.
Approved at head 7932bd887. The four blocking items and the mask finding from the earlier review are fixed.
- Audit:
audit_token_exchange()takespurposeand an optionaluser_email. - Tests: CI passes. The affected unit test files pass.
- CHANGELOG: the fail-closed resource read has a
Breaking Changesentry. - Commits: the
Co-authored-bytrailers are gone.Signed-off-bystays. - Mask: the masked secret is dropped on a grant switch, with regression tests.
One new finding. It does not block this approval.
severity | path:line | problem | fix
functionally-impacting | mcpgateway/admin.py:1241-1245 | _merge_discovery_edit() keeps the stored discovery client_secret when an admin clears the discovery client_id override. The discovery token request then pairs the parent client_id with the discovery secret and fails. | Drop the stored discovery client_secret when the client_id override is cleared or changed. Add a regression test.
suggestion | mcpgateway/services/gateway_service.py:7630 | _refresh_gateway_tools_resources_prompts guards empty results for authorization_code gateways only. Main has the same gap. | Reuse _discovery_result_is_unusable() here, or track it in a follow-up issue.
The earlier suggestions stay open as follow-ups: discovery inherits the parent scopes and client_secret, no negative cache, no invalidation on an upstream 401.
✨ Feature / Enhancement PR
🔗 Epic / Issue
Link to the epic or parent issue:
Closes #7236
🚀 Summary (1-2 sentences)
Platform admins can now create and edit
token-exchangegateways in the Admin UI. A token-exchange gateway can also have an optionaldiscoveryclient-credentials credential. Registration, "🔧 Fetch Tools", refresh, and health checks use it, and tool calls keep the per-user RFC 8693 exchange.📏 Reviewability
triage🧪 Checks
List exact commands, screenshots, videos, logs, validation steps, or explain why evidence is not feasible.
make lintpassesmake testpasses📓 Notes (optional)
User Story 1: token-exchange in the Admin UI
mcpgateway/templates/admin.html) offertoken-exchangeas a grant type. The option renders only whenis_adminis true.target_audience,subject_token_type,requested_token_type,subject_token_source, andtoken_endpoint_auth_method.private_key_jwtstays API-only._assemble_oauth_config_from_fields()inmcpgateway/admin.pyreads the new fields through_TOKEN_EXCHANGE_FORM_FIELDS._merge_token_exchange_edit()lays an edit submission over the storedoauth_config:private_key) keep their stored values.oauth_configunchanged._require_token_exchange_client_secret()rejects a UI submission without a usableclient_secret, unless the auth method isprivate_key_jwtornone. This check applies to the Admin UI only. The REST API does not change.lockTokenExchangeEditForNonAdmin()). The server-side platform-admin gate stays authoritative and returns 403.token_urlSSRF validation as the REST API.User Story 2: separate discovery credential
oauth_config.discoveryobject.discovery.grant_typeacceptsclient_credentialsonly.discoveryis rejected on all other grants.build_discovery_oauth_config()derives the effective client-credentials config:token_url,client_id,client_secret,token_endpoint_auth_method, and the signing-key keys from the parent.discoverycan overridetoken_url,client_id,client_secret, andtoken_endpoint_auth_method.discovery.token_urlgoes through the same egress validation as the parenttoken_url.GatewayService._resolve_discovery_header()gets the discovery token. These paths call it:_initialize_gateway(), health checks, reactivation, the lifecycle worker, update, and manual refresh (POST /oauth/fetch-tools/{id}andPOST /gateways/{id}/tools/refresh).tool_service,resource_service) never call the discovery resolver.test_invocation_services_never_reference_discovery_resolverpins this.GatewayServiceTokenCache(mcpgateway/services/gateway_service_token_cache.py), separate fromTokenExchangeCache:purpose:sha256(gateway_id):fingerprint.TokenExchangeCacheandGatewayServiceTokenCachenow share a_BearerTokenStorebase.TokenExchangeCachekeys do not change.audit_token_exchange()takes apurposefield (invocationordiscovery) and allowsuser_email=Nonefor service tokens. Each discovery request emits an audit event with acorrelation_id.OAuthManager.get_client_credentials_token_response()returns the full token response, so the cache can useexpires_in.Behavior without a discovery credential
Behavior stays as it is today. Registration persists an empty catalog, and health checks skip the gateway.
_discovery_result_is_unusable()now protects token-exchange gateways during update, reactivation, and the lifecycle worker. An empty result from a skipped discovery no longer deletes stored tools.Behavior with a discovery credential
GatewayConnectionError.Breaking or behavior changes
resource_serviceraisesResourceErrorwhen no user JWT is present or the exchange fails. Before, the request went upstream without anAuthorizationheader.purposekey. The change is additive.oauth_configJSON. Nesteddiscovery.client_secretvalues are encrypted at rest and masked in reads by the existing recursive handlers.Deny-path tests
tests/unit/mcpgateway/test_admin.py,test_main.py).test_admin_token_exchange_template.py).discovery.token_url, an unsupported discovery grant, and an unknown auth method are rejected (test_schemas_auth_validation.py,test_token_exchange_discovery.py).tests/live_gateway/mcp/test_token_exchange_discovery_credential.py. Mock upstream:tests/integration/test_token_exchange_mock_upstream.py. Playwright:tests/playwright/test_gateways.py.Docs
docs/docs/architecture/oauth-design.md: discovery with and without a discovery credential, invocation isolation, caching, audit.docs/docs/manage/identity-propagation.md: "Configuring token exchange in the Admin UI" and "Adding a discovery credential".sequenceDiagram participant Admin as Admin UI / scheduler participant GW as ContextForge participant AS as Authorization server participant MCP as Upstream MCP server participant User as MCP client (user) Admin->>GW: Register / Fetch Tools / health check GW->>AS: client_credentials (discovery) AS-->>GW: Service token (GatewayServiceTokenCache) GW->>MCP: initialize + tools/list (service token) User->>GW: tools/call (user JWT) GW->>AS: RFC 8693 exchange (subject_token = user JWT) AS-->>GW: User-scoped token (TokenExchangeCache) GW->>MCP: tools/call (user-scoped token)