From fdb050454e9dee5fc679c34fc4e18fa83860599d Mon Sep 17 00:00:00 2001 From: airano Date: Wed, 25 Feb 2026 23:27:59 +0330 Subject: [PATCH] feat: OAuth user fixes + dashboard UI improvements (v3.2.0) - Bug A: Fix OAuth consent redirect loop (return_url path-relative) - Bug B: Accept OAuth JWT tokens on /u/{user_id}/{alias}/mcp - Bug C: Add user OAuth client CRUD (list/create/delete) + new page - D-1: Sidebar border via Tailwind class, version footer in sidebar - D-2: Remove broken setInterval targeting #stats-container - D-3: 404.html purple primary colors + system dark mode support - D-4: Fix invisible buttons (bg-gray-200 + text-white in light mode) - D-5: Pin Alpine.js to 3.14.8, remove duplicate CSRF meta tag - D-6: Fix invisible lang toggle button in settings (dark mode) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 57 ++--- README.md | 4 +- core/dashboard/routes.py | 119 +++++++++ core/oauth/client_registry.py | 2 + core/oauth/schemas.py | 3 + core/templates/dashboard/404.html | 30 ++- core/templates/dashboard/api-keys/list.html | 14 +- core/templates/dashboard/base.html | 10 +- core/templates/dashboard/connect.html | 52 ++++ core/templates/dashboard/health/index.html | 2 +- core/templates/dashboard/index.html | 8 - .../dashboard/partials/head_assets.html | 4 +- core/templates/dashboard/projects/list.html | 10 +- core/templates/dashboard/settings/index.html | 4 +- .../dashboard/user-oauth-clients.html | 241 ++++++++++++++++++ core/user_endpoints.py | 75 ++++-- server.py | 5 +- 17 files changed, 538 insertions(+), 102 deletions(-) create mode 100644 core/templates/dashboard/user-oauth-clients.html diff --git a/CHANGELOG.md b/CHANGELOG.md index f86ff67..c0874ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,48 +5,25 @@ All notable changes to MCP Hub will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ---- +## [3.2.0] — 2026-02-25 -## [3.2.0] - 2026-02-24 +### Fixed +- **Bug A**: OAuth consent redirect loop — after GitHub/Google login, page now returns to consent screen instead of `/dashboard` +- **Bug B**: `/u/{user_id}/{alias}/mcp` endpoint now accepts OAuth JWT tokens (issued after social login) in addition to `mhu_` API keys +- **Bug C**: OAuth users can now create, list, and delete their own OAuth clients via the new `/dashboard/connect/oauth-clients` page +- **D-1**: Dashboard sidebar border uses Tailwind class instead of inline style; version shown in footer +- **D-2**: Removed broken `setInterval` in dashboard that targeted non-existent `#stats-container` +- **D-3**: 404 page uses purple primary colors and respects system dark mode preference +- **D-4**: Fixed invisible buttons (`bg-gray-200` + `text-white` in light mode) across api-keys, health, projects pages +- **D-5**: Pinned Alpine.js to 3.14.8; removed duplicate CSRF meta tag from `head_assets.html` +- **D-6**: Fixed invisible language toggle button in settings (dark mode) -### claude.ai Protocol Compatibility & Social Login Consent - -Adds full MCP Authorization Specification compliance for claude.ai Connectors, social login on the OAuth consent screen, and 15 bug fixes from comprehensive testing. - -#### Added -- **`client_secret_basic` Authentication** (RFC 6749 §2.3.1): Token endpoint now accepts client credentials via `Authorization: Basic` header in addition to `client_secret_post` -- **Token Revocation Endpoint** (RFC 7009): `POST /oauth/revoke` for revoking access and refresh tokens, with `client_secret_basic` support -- **`resource` Parameter** (RFC 8707): Authorization and token requests accept the `resource` parameter; JWT access tokens include the `aud` claim binding tokens to the MCP server URL -- **Social Login on OAuth Consent**: OAuth authorize page detects dashboard sessions, allowing users logged in via GitHub/Google to authorize MCP clients without re-entering API keys -- **Session-Based Consent**: `__session__` consent mode creates JWT tokens with `sub: "admin:..."` or `sub: "user:..."` claims based on session identity -- **Social Login Return URL**: `mcp_auth_next` cookie preserves OAuth flow state across social login redirects, with open redirect protection - -#### Fixed -- `get_system_info` returned hardcoded version `v2.6.0` instead of actual version -- Internal `"phase": "X.3"` debug field exposed in system info -- `/dashboard/audit-logs` caused redirect loop (removed from nav) -- Settings page used wrong sidebar layout -- Server mode displayed `sse` instead of `streamable-http` -- Plugin descriptions showed "No description" when description existed -- 404 page was unstyled plain text (now styled standalone page) -- OAuth flows failed without `PUBLIC_URL` env var -- Endpoint page showed wrong example tools per project -- Health check timestamps showed "None" for new projects -- Health checks missing `auth_valid` field causing dashboard errors -- `ENCRYPTION_KEY` not validated on startup (now fails fast with clear error) -- Stale JWT tokens caused 500 errors instead of 401 -- Per-user MCP endpoint returned 500 `ImportError` on first request -- Copy button displayed Python `repr()` instead of clean JSON - -#### Improved -- API key modal stays open until user clicks "Done" (was 0.5s auto-close) -- `health_metric_recorded` events filtered from project activity feed -- WooCommerce health checks use `consumer_key`/`consumer_secret` authentication - -#### Tests -- Test suite expanded from 290 to 442 tests (+152) -- Added 23 Phase B OAuth protocol tests -- Added Phase C social login consent tests +### Added +- `OAuthClient.owner_user_id` field for per-user OAuth client isolation +- `dashboard_connect_page` now includes Claude.ai connection guide with endpoint URL and link to OAuth Clients +- New route: `GET /dashboard/connect/oauth-clients` — user's OAuth clients list +- New API: `POST /api/dashboard/user-oauth-clients/create` — create OAuth client for logged-in user +- New API: `DELETE /api/dashboard/user-oauth-clients/{client_id}` — delete own OAuth client --- diff --git a/README.md b/README.md index b9f05ce..f8c3952 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Connect your sites, stores, repos, and databases — manage them all through Cla [![Python 3.11+](https://img.shields.io/badge/python-3.11+-3776ab.svg)](https://www.python.org/) [![PyPI](https://img.shields.io/pypi/v/mcphub-server.svg)](https://pypi.org/project/mcphub-server/) [![Docker](https://img.shields.io/docker/v/airano/mcphub?label=docker)](https://hub.docker.com/r/airano/mcphub) -[![Tests: 442 passing](https://img.shields.io/badge/tests-442%20passing-brightgreen.svg)]() +[![Tests: 290 passing](https://img.shields.io/badge/tests-290%20passing-brightgreen.svg)]() [![Tools: 596](https://img.shields.io/badge/tools-596-orange.svg)]() [![CI](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml/badge.svg)](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml) @@ -363,7 +363,7 @@ Without Docker socket, WP-CLI tools return "not available" but all REST API tool # Install with dev dependencies pip install -e ".[dev]" -# Run tests (442 tests) +# Run tests (290 tests) pytest # Format and lint diff --git a/core/dashboard/routes.py b/core/dashboard/routes.py index 0660eed..d86c9c4 100644 --- a/core/dashboard/routes.py +++ b/core/dashboard/routes.py @@ -2696,6 +2696,7 @@ async def dashboard_connect_page(request: Request) -> Response: "clients": get_supported_clients(), "current_page": "connect", "new_key": new_key, + "public_url": os.environ.get("PUBLIC_URL", "http://localhost:8000"), }, ) @@ -2902,6 +2903,110 @@ async def api_get_config(request: Request) -> Response: return JSONResponse({"error": str(e)}, status_code=400) +async def dashboard_user_oauth_clients_list(request: Request) -> Response: + """GET /dashboard/connect/oauth-clients — OAuth user's own OAuth clients.""" + user_session, redirect = _require_user_session(request) + if redirect: + return redirect + + accept_language = request.headers.get("accept-language") + query_lang = request.query_params.get("lang") + lang = detect_language(accept_language, query_lang) + t = get_translations(lang) + + from core.oauth import get_client_registry as _get_client_registry + + registry = _get_client_registry() + user_id = user_session["user_id"] + user_clients = [c for c in registry.list_clients() if c.owner_user_id == user_id] + + return templates.TemplateResponse( + "dashboard/user-oauth-clients.html", + { + "request": request, + "lang": lang, + "t": t, + "session": user_session, + "clients": user_clients, + "current_page": "connect", + }, + ) + + +async def dashboard_user_oauth_clients_create(request: Request) -> Response: + """POST /api/dashboard/user-oauth-clients/create — Create OAuth client for OAuth user.""" + user_session, redirect = _require_user_session(request) + if redirect: + return JSONResponse({"error": "Unauthorized"}, status_code=401) + + try: + body = await request.json() + except Exception: + return JSONResponse({"error": "Invalid JSON"}, status_code=400) + + client_name = body.get("client_name", "").strip() + redirect_uris_raw = body.get("redirect_uris", "") + scopes = body.get("scopes", ["read", "write"]) + + if not client_name: + return JSONResponse({"error": "Client name required"}, status_code=400) + + if isinstance(redirect_uris_raw, str): + redirect_uris = [u.strip() for u in redirect_uris_raw.splitlines() if u.strip()] + else: + redirect_uris = [u.strip() for u in redirect_uris_raw if u.strip()] + + if not redirect_uris: + return JSONResponse({"error": "At least one redirect URI required"}, status_code=400) + + scope_str = " ".join(scopes) if isinstance(scopes, list) else scopes + + from core.oauth import get_client_registry as _get_client_registry + + registry = _get_client_registry() + client_id, client_secret = registry.create_client( + client_name=client_name, + redirect_uris=redirect_uris, + allowed_scopes=scope_str.split() if isinstance(scope_str, str) else scopes, + owner_user_id=user_session["user_id"], + ) + client = registry.get_client(client_id) + + return JSONResponse( + { + "client_id": client_id, + "client_secret": client_secret, + "client_name": client.client_name, + "redirect_uris": client.redirect_uris, + "scope": client.scope, + } + ) + + +async def dashboard_user_oauth_clients_delete(request: Request) -> Response: + """DELETE /api/dashboard/user-oauth-clients/{client_id} — Delete user's own OAuth client.""" + user_session, redirect = _require_user_session(request) + if redirect: + return JSONResponse({"error": "Unauthorized"}, status_code=401) + + client_id = request.path_params.get("client_id", "") + + from core.oauth import get_client_registry as _get_client_registry + + registry = _get_client_registry() + client = registry.get_client(client_id) + + if not client: + return JSONResponse({"error": "Client not found"}, status_code=404) + + # Only allow deleting own clients + if client.owner_user_id != user_session["user_id"]: + return JSONResponse({"error": "Access denied"}, status_code=403) + + registry.delete_client(client_id) + return JSONResponse({"success": True}) + + def register_dashboard_routes(mcp): """ Register dashboard routes with the MCP server. @@ -2911,6 +3016,9 @@ def register_dashboard_routes(mcp): """ logger.info("Registering dashboard routes...") + # Set template globals (available in all templates without passing explicitly) + templates.env.globals["project_version"] = _get_project_version() + # Auth routes (E.2: OAuth Social Login) mcp.custom_route("/auth/login", methods=["GET"])(auth_login_page) mcp.custom_route("/auth/callback/{provider}", methods=["GET"])(auth_callback) @@ -2998,4 +3106,15 @@ def register_dashboard_routes(mcp): # Config snippet API (E.3) mcp.custom_route("/api/config/{alias}", methods=["GET"])(api_get_config) + # User OAuth Client routes (Bug C fix) + mcp.custom_route("/dashboard/connect/oauth-clients", methods=["GET"])( + dashboard_user_oauth_clients_list + ) + mcp.custom_route("/api/dashboard/user-oauth-clients/create", methods=["POST"])( + dashboard_user_oauth_clients_create + ) + mcp.custom_route("/api/dashboard/user-oauth-clients/{client_id}", methods=["DELETE"])( + dashboard_user_oauth_clients_delete + ) + logger.info("Dashboard routes registered successfully") diff --git a/core/oauth/client_registry.py b/core/oauth/client_registry.py index 36a0ce0..bcf3218 100644 --- a/core/oauth/client_registry.py +++ b/core/oauth/client_registry.py @@ -76,6 +76,7 @@ class ClientRegistry: grant_types: list[str] | None = None, allowed_scopes: list[str] | None = None, metadata: dict | None = None, + owner_user_id: str | None = None, ) -> tuple[str, str]: """ Create new OAuth client. @@ -99,6 +100,7 @@ class ClientRegistry: grant_types=grant_types or ["authorization_code", "refresh_token"], allowed_scopes=allowed_scopes or ["read", "write"], metadata=metadata or {}, + owner_user_id=owner_user_id, ) # Save diff --git a/core/oauth/schemas.py b/core/oauth/schemas.py index c2687ec..9c39f47 100644 --- a/core/oauth/schemas.py +++ b/core/oauth/schemas.py @@ -27,6 +27,9 @@ class OAuthClient(BaseModel): ) created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) metadata: dict = Field(default_factory=dict) + owner_user_id: str | None = Field( + default=None, description="ID of the OAuth user who created this client (None = admin)" + ) @field_validator("redirect_uris") def validate_redirect_uris(cls, v): diff --git a/core/templates/dashboard/404.html b/core/templates/dashboard/404.html index b5673a5..25dcfb4 100644 --- a/core/templates/dashboard/404.html +++ b/core/templates/dashboard/404.html @@ -1,5 +1,5 @@ - + @@ -11,23 +11,35 @@ theme: { extend: { colors: { - primary: { 400: '#60a5fa', 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8' } + primary: { + 50: '#f5f3ff', + 100: '#ede9fe', + 400: '#a78bfa', + 500: '#8b5cf6', + 600: '#7c3aed', + 700: '#6d28d9', + } } } } } + - +
- +
-

404

-

Page not found

-

The page you're looking for doesn't exist or has been moved.

+

404

+

Page Not Found

+

The page you're looking for doesn't exist or has been moved.

-

MCP Hub

+

MCP Hub

diff --git a/core/templates/dashboard/api-keys/list.html b/core/templates/dashboard/api-keys/list.html index 7f74556..f1a8382 100644 --- a/core/templates/dashboard/api-keys/list.html +++ b/core/templates/dashboard/api-keys/list.html @@ -76,7 +76,7 @@ {% if search_query or selected_project or selected_status != 'active' %} - + {{ t['clear'] }} {% endif %} @@ -248,7 +248,7 @@
{% if page_number > 1 %} + class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white transition-colors"> {% if lang == 'fa' %}قبلی{% else %}Previous{% endif %} {% endif %} @@ -258,7 +258,7 @@ {{ page_num }} {% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 2 and page_num <= page_number + 2) %} + class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white transition-colors"> {{ page_num }} {% elif page_num == page_number - 3 or page_num == page_number + 3 %} @@ -268,7 +268,7 @@ {% if page_number < total_pages %} + class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white transition-colors"> {% if lang == 'fa' %}بعدی{% else %}Next{% endif %} {% endif %} @@ -360,7 +360,7 @@
@@ -448,7 +448,7 @@
@@ -485,7 +485,7 @@
diff --git a/core/templates/dashboard/base.html b/core/templates/dashboard/base.html index 0d034c4..64ac314 100644 --- a/core/templates/dashboard/base.html +++ b/core/templates/dashboard/base.html @@ -102,9 +102,8 @@ })">
-