refactor: Update JWT client to use DCR, re-enable tool filtering
This commit is contained in:
+8
-10
@@ -92,21 +92,19 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:8002:8002
|
- 127.0.0.1:8002:8002
|
||||||
environment:
|
environment:
|
||||||
#- NEXTCLOUD_HOST=http://app:80
|
- NEXTCLOUD_HOST=http://app:80
|
||||||
- NEXTCLOUD_HOST=http://host.docker.internal:8080
|
|
||||||
- NEXTCLOUD_MCP_SERVER_URL=http://127.0.0.1:8002
|
- NEXTCLOUD_MCP_SERVER_URL=http://127.0.0.1:8002
|
||||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://127.0.0.1:8080
|
||||||
- NEXTCLOUD_OIDC_CLIENT_STORAGE=/var/www/html/.oauth-jwt/nextcloud_oauth_client.json
|
- NEXTCLOUD_OIDC_CLIENT_STORAGE=/app/.oauth-jwt/nextcloud_oauth_client.json
|
||||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
|
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
|
||||||
# No USERNAME/PASSWORD - will use OAuth with JWT tokens
|
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
|
||||||
# Client credentials auto-generated by app container post-installation hook
|
# No USERNAME/PASSWORD - will use OAuth with Dynamic Client Registration (DCR)
|
||||||
|
# Client will be registered with token_type=JWT on first startup
|
||||||
volumes:
|
volumes:
|
||||||
# NOTE: JWT-enabled OIDC client credentials created during nextcloud installation scripts
|
- oauth-jwt-client-storage:/app/.oauth-jwt
|
||||||
- nextcloud:/var/www/html:ro
|
|
||||||
extra_hosts:
|
|
||||||
- host.docker.internal:host-gateway
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
nextcloud:
|
nextcloud:
|
||||||
db:
|
db:
|
||||||
oauth-client-storage:
|
oauth-client-storage:
|
||||||
|
oauth-jwt-client-storage:
|
||||||
|
|||||||
+28
-10
@@ -19,6 +19,7 @@ from nextcloud_mcp_server.auth import (
|
|||||||
NextcloudTokenVerifier,
|
NextcloudTokenVerifier,
|
||||||
get_access_token_scopes,
|
get_access_token_scopes,
|
||||||
has_required_scopes,
|
has_required_scopes,
|
||||||
|
is_jwt_token,
|
||||||
)
|
)
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
from nextcloud_mcp_server.config import LOGGING_CONFIG, setup_logging
|
from nextcloud_mcp_server.config import LOGGING_CONFIG, setup_logging
|
||||||
@@ -195,6 +196,14 @@ async def load_oauth_client_credentials(
|
|||||||
)
|
)
|
||||||
logger.info(f"Requesting OAuth scopes: {scopes}")
|
logger.info(f"Requesting OAuth scopes: {scopes}")
|
||||||
|
|
||||||
|
# Get token type from environment (Bearer or jwt)
|
||||||
|
# Note: Must be lowercase "jwt" to match OIDC app's check
|
||||||
|
token_type = os.getenv("NEXTCLOUD_OIDC_TOKEN_TYPE", "Bearer").lower()
|
||||||
|
# Special case: "bearer" should remain capitalized for compatibility
|
||||||
|
if token_type != "jwt":
|
||||||
|
token_type = "Bearer"
|
||||||
|
logger.info(f"Requesting token type: {token_type}")
|
||||||
|
|
||||||
# Load or register client
|
# Load or register client
|
||||||
from nextcloud_mcp_server.auth.client_registration import (
|
from nextcloud_mcp_server.auth.client_registration import (
|
||||||
load_or_register_client,
|
load_or_register_client,
|
||||||
@@ -207,6 +216,7 @@ async def load_oauth_client_credentials(
|
|||||||
client_name="Nextcloud MCP Server",
|
client_name="Nextcloud MCP Server",
|
||||||
redirect_uris=redirect_uris,
|
redirect_uris=redirect_uris,
|
||||||
scopes=scopes,
|
scopes=scopes,
|
||||||
|
token_type=token_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"OAuth client ready: {client_info.client_id[:16]}...")
|
logger.info(f"OAuth client ready: {client_info.client_id[:16]}...")
|
||||||
@@ -464,40 +474,48 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
original_list_tools = mcp._tool_manager.list_tools
|
original_list_tools = mcp._tool_manager.list_tools
|
||||||
|
|
||||||
def list_tools_filtered():
|
def list_tools_filtered():
|
||||||
"""List tools filtered by user's token scopes."""
|
"""List tools filtered by user's token scopes (JWT tokens only)."""
|
||||||
# Get user's scopes from token using MCP SDK's contextvar
|
# Get user's scopes from token using MCP SDK's contextvar
|
||||||
# This works for all request types including list_tools
|
# This works for all request types including list_tools
|
||||||
user_scopes = get_access_token_scopes()
|
user_scopes = get_access_token_scopes()
|
||||||
logger.info(f"🔍 list_tools called - User scopes: {user_scopes}")
|
is_jwt = is_jwt_token()
|
||||||
|
logger.info(
|
||||||
|
f"🔍 list_tools called - Token type: {'JWT' if is_jwt else 'opaque/none'}, "
|
||||||
|
f"User scopes: {user_scopes}"
|
||||||
|
)
|
||||||
|
|
||||||
# Get all tools
|
# Get all tools
|
||||||
all_tools = original_list_tools()
|
all_tools = original_list_tools()
|
||||||
|
|
||||||
# If OAuth mode and user has scopes, filter by them
|
# Only filter for JWT tokens (opaque tokens show all tools)
|
||||||
# TODO: Re-enable once OIDC clients respect allowed_scopes from PRM
|
# JWT tokens have scopes embedded, so we can reliably filter
|
||||||
if 1 == 0: # user_scopes:
|
# Opaque tokens may not have accurate scope information from introspection
|
||||||
|
if is_jwt and user_scopes:
|
||||||
allowed_tools = [
|
allowed_tools = [
|
||||||
tool
|
tool
|
||||||
for tool in all_tools
|
for tool in all_tools
|
||||||
if has_required_scopes(tool.fn, user_scopes)
|
if has_required_scopes(tool.fn, user_scopes)
|
||||||
]
|
]
|
||||||
logger.info(
|
logger.info(
|
||||||
f"✂️ Filtered tools: {len(allowed_tools)}/{len(all_tools)} tools "
|
f"✂️ JWT scope filtering: {len(allowed_tools)}/{len(all_tools)} tools "
|
||||||
f"available for scopes: {user_scopes}"
|
f"available for scopes: {user_scopes}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# BasicAuth mode or no token - show all tools
|
# Opaque token, BasicAuth mode, or no token - show all tools
|
||||||
allowed_tools = all_tools
|
allowed_tools = all_tools
|
||||||
logger.info(
|
reason = (
|
||||||
f"📋 No scope filtering: showing all {len(all_tools)} tools"
|
"opaque token (no filtering)"
|
||||||
|
if not is_jwt and user_scopes
|
||||||
|
else "no token/BasicAuth"
|
||||||
)
|
)
|
||||||
|
logger.info(f"📋 Showing all {len(all_tools)} tools ({reason})")
|
||||||
|
|
||||||
# Return the Tool objects directly (they're already in the correct format)
|
# Return the Tool objects directly (they're already in the correct format)
|
||||||
return allowed_tools
|
return allowed_tools
|
||||||
|
|
||||||
# Replace the tool manager's list_tools method
|
# Replace the tool manager's list_tools method
|
||||||
mcp._tool_manager.list_tools = list_tools_filtered
|
mcp._tool_manager.list_tools = list_tools_filtered
|
||||||
logger.info("Dynamic tool filtering enabled for OAuth mode")
|
logger.info("Dynamic tool filtering enabled for OAuth mode (JWT tokens only)")
|
||||||
|
|
||||||
if transport == "sse":
|
if transport == "sse":
|
||||||
mcp_app = mcp.sse_app()
|
mcp_app = mcp.sse_app()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from .scope_authorization import (
|
|||||||
get_access_token_scopes,
|
get_access_token_scopes,
|
||||||
get_required_scopes,
|
get_required_scopes,
|
||||||
has_required_scopes,
|
has_required_scopes,
|
||||||
|
is_jwt_token,
|
||||||
require_scopes,
|
require_scopes,
|
||||||
)
|
)
|
||||||
from .token_verifier import NextcloudTokenVerifier
|
from .token_verifier import NextcloudTokenVerifier
|
||||||
@@ -27,4 +28,5 @@ __all__ = [
|
|||||||
"get_access_token_scopes",
|
"get_access_token_scopes",
|
||||||
"get_required_scopes",
|
"get_required_scopes",
|
||||||
"has_required_scopes",
|
"has_required_scopes",
|
||||||
|
"is_jwt_token",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -213,6 +213,30 @@ def get_required_scopes(func: Callable) -> list[str]:
|
|||||||
return getattr(func, "_required_scopes", [])
|
return getattr(func, "_required_scopes", [])
|
||||||
|
|
||||||
|
|
||||||
|
def is_jwt_token() -> bool:
|
||||||
|
"""
|
||||||
|
Check if the current access token is in JWT format.
|
||||||
|
|
||||||
|
JWT tokens have 3 parts separated by dots (header.payload.signature).
|
||||||
|
Opaque tokens are random strings without this structure.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if current token is JWT format, False if opaque or no token
|
||||||
|
"""
|
||||||
|
access_token: AccessToken | None = get_access_token()
|
||||||
|
|
||||||
|
if access_token is None:
|
||||||
|
logger.debug("No access token found - not JWT")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# JWT tokens have exactly 2 dots (3 parts)
|
||||||
|
token_string = access_token.token
|
||||||
|
is_jwt = "." in token_string and token_string.count(".") == 2
|
||||||
|
|
||||||
|
logger.debug(f"Token format check: is_jwt={is_jwt}")
|
||||||
|
return is_jwt
|
||||||
|
|
||||||
|
|
||||||
def has_required_scopes(func: Callable, user_scopes: set[str]) -> bool:
|
def has_required_scopes(func: Callable, user_scopes: set[str]) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a user has all scopes required by a function.
|
Check if a user has all scopes required by a function.
|
||||||
|
|||||||
Vendored
+1
-1
Submodule third_party/oidc updated: 536dab20b3...f7f80b72d5
Reference in New Issue
Block a user