feat(python): MCP server OAuth form quick wins (read-only redirect URI, drop password grant) - #5786
feat(python): MCP server OAuth form quick wins (read-only redirect URI, drop password grant)#5786Altamimi-Dev wants to merge 3 commits into
Conversation
9282964 to
5b0ff1c
Compare
0a994c6 to
10d27fa
Compare
ae83864 to
8c48df7
Compare
8c48df7 to
d211648
Compare
|
Hi, @Altamimi-Dev! Thank you for your contribution. To keep the future merging of this branch |
53bca45 to
498842e
Compare
498842e to
e4d1d93
Compare
|
Hey @Altamimi-Dev — thanks for this, the redirect URI and password grant fixes are exactly what #5721 needed. A heads up on what changed here: since
To do that I force-pushed this branch, so the history here is now just those backend commits rebased onto The UI piece (read-only derived redirect URI with copy button + localhost warning, password grant hidden except for legacy configs) is carried over as its own PR against the new UI repo: contextforge-org/contextforge-web-ui#15. Let me know if anything here looks off. |
|
Thanks for taking the time to do the split yourself, @marekdano — and for the heads-up on the force-push. I still have the old epic-based head on a local branch, so nothing was lost on my side. I went through both halves and the rescope looks good to me: Backend (this PR) — both reviewed pieces survived intact:
UI (contextforge-org/contextforge-web-ui#15) — reads as a faithful port of the reviewed behavior:
One small thing you may want to weigh: the And yes please on the co-author credit, much appreciated: For context on the two related PRs: I'm applying the same treatment to #5930 (retargeted to |
|
LGTM! 🚀 |
msureshkumar88
left a comment
There was a problem hiding this comment.
Thanks for tackling the OAuth password-grant deprecation and redirect_uri defaulting from #5721 — the backend direction (rejecting the deprecated grant at the schema layer, defaulting redirect_uri server-side) is the right approach, and the added tests give reasonable coverage for the code paths that were touched.
A few things worth addressing before merge:
Scope vs. PR description
The PR summary describes a React UI change (read-only redirect URI field with copy button, grant-type selector with password removed) as delivered, but the diff against main only touches mcpgateway/routers/oauth_router.py, mcpgateway/schemas.py, and three test files — no client/ or UI files are present. It looks like that UI work lives on a separate branch (epic/ui-rewrite) rather than this PR. Worth correcting the description (or pulling the UI piece in) so reviewers don't approve believing Stories 1 and 4's UI requirements are covered here.
Security: password grant still reachable via update path
GatewayCreate rejects grant_type == "password" in schemas.py, but GatewayUpdate.validate_oauth_config (~line 3581) only calls _validate_oauth_config_urls and doesn't apply the same check. Since GatewayUpdate backs the public PUT/PATCH /gateways/{id} endpoint, an existing gateway can still be flipped to the deprecated password grant through the update path. This should mirror the same rejection as GatewayCreate — otherwise the fix is bypassable.
UI: legacy admin form still offers the deprecated grant
mcpgateway/templates/admin.html (~line 5495) still lists <option value="password">Resource Owner Password Credentials (Keycloak/Legacy)</option> in the gateway-creation form with no guard. With the schema-level rejection now in place, selecting it in the UI will hit a 422 with no prior warning — worth removing/guarding the option here too, both for UX and to actually satisfy Story 4's grant-selector requirement in the UI that does ship.
redirect_uri default doesn't account for app_root_path
_default_redirect_uri() (~line 119) builds the callback URL from settings.app_domain alone. Other URL builders in the codebase (e.g. email_auth_service.py) combine app_domain with app_root_path/request-scoped root_path for exactly this reason. In a reverse-proxied deployment with a non-empty root path, the default redirect_uri would be wrong. Suggest aligning with the existing pattern.
Missing logging when the default is used
Issue #5721's security requirements call out that domain misconfiguration should be surfaced, not silently mis-derived. Right now _default_redirect_uri() doesn't log when it's invoked (including when app_domain falls back to localhost), so there's no server-side signal for an operator debugging a failed callback in production. A log line at use would help close that gap.
redirect_uri recomputed at callback instead of pinned at authorize
The default-uri guard runs independently at authorize time (~line 540) and again at callback time (~line 818), both reading live gateway state. If the gateway's oauth_config changes between authorize and callback (e.g. a concurrent update), the value sent to the IdP at authorize and the value used at callback can diverge, breaking the token exchange per RFC 6749 §4.1.3. Pinning the computed redirect_uri at authorize time (e.g. in session/state) and reusing it at callback would avoid this.
Minor: duplicated guard logic
The if not oauth_config.get("redirect_uri"): oauth_config["redirect_uri"] = _default_redirect_uri() block is duplicated in both initiate_oauth_flow and oauth_callback. Since the actual failure this PR fixes originates in OAuthManager (which indexes credentials["redirect_uri"] directly), centralizing the default there would prevent the bug from resurfacing if a future call path bypasses these two router functions. Non-blocking, but worth a follow-up.
No Alembic migration is needed here (no schema change), and that's correctly reflected in the diff.
|
@Altamimi-Dev - I've pushed the updates to unblock this PR and get it merged asap. |
…rver-side Scoped to the backend-only pieces of IBM#5721: GatewayCreate rejects the OAuth 2.1 resource owner password grant for new MCP server registrations (GatewayUpdate still accepts it for existing records), and initiate_oauth_flow/oauth_callback default a missing redirect_uri to {app_domain}/oauth/callback so authorization_code configs created via the API or predating this change don't hit OAuthManager's PKCE paths without one. Signed-off-by: Marek Dano <mk.dano@gmail.com>
…ix root_path default - GatewayUpdate now rejects newly adopting the deprecated password grant on a gateway that wasn't already using it, closing the path that let the GatewayCreate-time rejection be bypassed via update. - redirect_uri defaulting now accounts for app_root_path and logs when applied; the fallback is centralized in OAuthManager instead of duplicated across both /oauth router endpoints, so any future caller is protected too. - redirect_uri is pinned into OAuth state at authorize time and reused at token exchange, so it can't diverge between the two legs if gateway config or app_domain changes mid-flow (RFC 6749 §4.1.3). - Removes the deprecated password grant option from the legacy admin gateway-creation form (edit form still allows it, for existing configs). - Adds redirect_uri column to oauth_states (migration included). Signed-off-by: Marek Dano <Marek.Dano@ibm.com>
Signed-off-by: Ahmad Al Tamimi <altamimi.dev@gmail.com>
5d0598d to
03867ac
Compare
|
@marekdano thanks a lot for jumping in with the review-fix commits — they cover every point @msureshkumar88 raised (password-grant rejection on the update path, the I've rebased the branch onto current
The PR now shows as mergeable. Should be good to go once CI is green. |
msureshkumar88
left a comment
There was a problem hiding this comment.
Verified all requested changes from the earlier review are addressed:
GatewayUpdatenow rejects newly adopting the password grant on a gateway that wasn't already using it (gateway_service.py), closing the create-time bypass.- Legacy admin gateway-creation form no longer offers the password grant option; edit form retains it for existing configs.
_default_redirect_uri()now accounts forapp_root_pathviaresolve_root_path(), and logs when the default is applied (both authorize and callback legs, centralized inOAuthManager._apply_default_redirect_uri).redirect_uriis now pinned intooauth_statesat authorize time and reused at callback (RFC 6749 §4.1.3), with migrationdb41939315aaadding the column.- Ran the PR's listed test commands locally — 71/71 pass.
One minor, non-blocking observation: the gateway edit form (admin.html ~line 10066) still unconditionally lists the password grant option for every gateway regardless of current grant type. Selecting it on a non-password gateway now hits the new 422 rejection with no UI guard — a small UX gap, not a functional blocker. Worth a quick follow-up to disable/hide the option when the gateway isn't already on password grant.
Approving — backend changes are correct, well-tested, and match the PR description.
|
Closing in favor of #6315, which carries these same commits (unchanged authorship/sign-off from @marekdano, opened here by @Altamimi-Dev) rebased onto current main. That PR's head lives on a fork this account can't push to, so the rebase had to land as a fresh PR — see credit note there. #6315 has been merged. |
Pull Request
🔗 Related Issue
Relates to #5721 (delivers the backend pieces of User Stories 1, 2, and 4; Story 3 — pre-fill from MCP
initialize— is deferred, see #5719)📝 Summary
Backend half of the MCP server OAuth form quick wins: closes an OAuth 2.1 foot-gun (deprecated password grant) and hardens
redirect_urihandling for the authorization-code flow. The React UI half (read-only derived redirect URI field, grant-type selector) now lives in contextforge-org/contextforge-web-ui#15 —client/has moved to its own repo andepic/ui-rewritehad diverged too far frommainto keep both halves in one PR, so this one was rescoped to targetmaindirectly.GatewayCreaterejectsgrant_type == "password"at the schema layer, so new MCP server registrations can't use it regardless of how they're created (UI or API). Existing gateways that already use it keep working throughGatewayUpdate— butGatewayUpdatenow also rejects newly adopting password on a gateway that wasn't already using it, so the create-time restriction can't be bypassed by flipping an existing gateway's grant type via the update path. The legacy admin UI's gateway creation form no longer offers the option either (the edit form still does, for loading/saving existing legacy configs).redirect_uridefaulting (Story 1, server-side):initiate_oauth_flowandoauth_callbackdefault a missingredirect_urito the gateway's own global callback ({app_domain}{app_root_path}/oauth/callback), so API-created and legacy configs can't reachOAuthManager's PKCE paths without one. The default accounts for a reverse-proxyapp_root_path, logs when it's actually applied, and the substitution logic is centralized inOAuthManager(rather than duplicated per call site) so any future caller is protected the same way.redirect_uripinned across the flow: the value sent to the IdP at authorize time is now pinned into server-side OAuth state (alongside the PKCEcode_verifier) and reused at token-exchange time, instead of each side independently recomputing it — so a concurrent gateway config change orapp_domainchange between authorize and callback can't cause the two to diverge (RFC 6749 §4.1.3 requires them to match). Adds aredirect_uricolumn tooauth_states(migration included).resourcefrom the normalised MCP server URL when unset).📏 Reviewability
triage🏷️ Type of Change
🧪 Verification
pytest tests/unit/mcpgateway/test_schemas_validators_extra.py tests/unit/mcpgateway/services/test_gateway_service.py -k "oauth or gateway_create or gateway_update or password"pytest tests/unit/mcpgateway/routers/test_oauth_router.pypytest tests/unit/mcpgateway/services/test_oauth_manager.pymake ruff interrogatealembic headsManual:
GatewayCreatewithgrant_type=passwordis rejected;GatewayUpdateaccepts it only when the gateway already used it, and rejects flipping a non-password gateway to it. A gateway with noredirect_uriconfigured gets one derived and logged on both the authorize and callback legs, matching each other even if config changes mid-flow.✅ Checklist