diff --git a/docker-compose.yml b/docker-compose.yml index 8828e9b..26e1516 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -165,6 +165,9 @@ services: - TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo= - TOKEN_STORAGE_DB=/app/data/tokens.db + # Token exchange (RFC 8693) - convert aud:nextcloud-mcp-server → aud:nextcloud + - ENABLE_TOKEN_EXCHANGE=true + # OAuth scopes (optional - uses defaults if not specified) - NEXTCLOUD_OIDC_SCOPES=openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index da5835d..1dd21fa 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -580,6 +580,7 @@ async def setup_oauth_config(): oidc_discovery_url=discovery_url, nextcloud_host=nextcloud_host, encryption_key=encryption_key, + mcp_client_id=client_id, ) logger.info( @@ -753,10 +754,16 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): f"Unknown app: {app_name}. Available apps: {list(available_apps.keys())}" ) - # Register OAuth provisioning tools (Progressive Consent always enabled in OAuth mode) - if oauth_enabled: + # Register OAuth provisioning tools (only when offline access/Progressive Consent is used) + # With token exchange enabled (external IdP), provisioning is not needed for MCP operations + enable_token_exchange = ( + os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true" + ) + if oauth_enabled and not enable_token_exchange: logger.info("Registering OAuth provisioning tools for Progressive Consent") register_oauth_tools(mcp) + elif oauth_enabled and enable_token_exchange: + logger.info("Skipping provisioning tools registration (token exchange enabled)") # Override list_tools to filter based on user's token scopes (OAuth mode only) if oauth_enabled: diff --git a/nextcloud_mcp_server/auth/context_helper.py b/nextcloud_mcp_server/auth/context_helper.py index 867abc1..a964053 100644 --- a/nextcloud_mcp_server/auth/context_helper.py +++ b/nextcloud_mcp_server/auth/context_helper.py @@ -7,7 +7,7 @@ from mcp.server.fastmcp import Context from ..client import NextcloudClient from ..config import get_settings -from .token_exchange import exchange_token_for_delegation +from .token_exchange import exchange_token_for_audience logger = logging.getLogger(__name__) @@ -118,25 +118,23 @@ async def get_session_client_from_context( logger.error("No username found in access token resource field") raise ValueError("Username not available in OAuth token context") - logger.info("Exchanging Flow 1 token for ephemeral Nextcloud token") + logger.info("Exchanging client token for Nextcloud API token (pure RFC 8693)") - # Perform RFC 8693 token exchange + # Perform pure RFC 8693 token exchange (no refresh tokens) # Note: We don't pass scopes since Nextcloud doesn't enforce them. # The MCP server's @require_scopes decorator handles authorization. - delegated_token, expires_in = await exchange_token_for_delegation( - flow1_token=flow1_token, - requested_scopes=None, # Nextcloud doesn't support scopes + exchanged_token, expires_in = await exchange_token_for_audience( + subject_token=flow1_token, requested_audience="nextcloud", + requested_scopes=None, # Nextcloud doesn't support scopes ) - logger.info( - f"Token exchange successful. Ephemeral token expires in {expires_in}s" - ) + logger.info(f"Pure token exchange successful. Token expires in {expires_in}s") - # Create client with ephemeral delegated token - # This token is NOT stored and will be discarded after use + # Create client with exchanged token + # This token is ephemeral (per-request) and NOT stored return NextcloudClient.from_token( - base_url=base_url, token=delegated_token, username=username + base_url=base_url, token=exchanged_token, username=username ) except AttributeError as e: @@ -144,6 +142,4 @@ async def get_session_client_from_context( raise except Exception as e: logger.error(f"Token exchange failed: {e}") - # Fall back to standard OAuth flow if token exchange fails - logger.info("Falling back to standard OAuth flow") - return get_client_from_context(ctx, base_url) + raise RuntimeError(f"Token exchange required but failed: {e}") from e diff --git a/nextcloud_mcp_server/auth/progressive_token_verifier.py b/nextcloud_mcp_server/auth/progressive_token_verifier.py index d278970..d556b42 100644 --- a/nextcloud_mcp_server/auth/progressive_token_verifier.py +++ b/nextcloud_mcp_server/auth/progressive_token_verifier.py @@ -2,7 +2,7 @@ Token Verifier for ADR-004 Progressive Consent Architecture. This module implements token verification with strict audience separation: -- Flow 1 tokens have aud: "mcp-server" for MCP authentication +- Flow 1 tokens have aud: for MCP authentication - Flow 2 tokens have aud: "nextcloud" for resource access - Token Broker manages the exchange between audiences """ @@ -26,7 +26,7 @@ class ProgressiveConsentTokenVerifier: Token verifier for Progressive Consent dual OAuth flows. This verifier: - 1. Validates Flow 1 tokens (aud: "mcp-server") for MCP authentication + 1. Validates Flow 1 tokens (aud: ) for MCP authentication 2. Checks if user has provisioned Nextcloud access (Flow 2) 3. Uses Token Broker to obtain aud: "nextcloud" tokens when needed """ @@ -38,6 +38,7 @@ class ProgressiveConsentTokenVerifier: oidc_discovery_url: Optional[str] = None, nextcloud_host: Optional[str] = None, encryption_key: Optional[str] = None, + mcp_client_id: Optional[str] = None, ): """ Initialize the Progressive Consent token verifier. @@ -48,6 +49,7 @@ class ProgressiveConsentTokenVerifier: oidc_discovery_url: OIDC provider discovery URL nextcloud_host: Nextcloud server URL encryption_key: Fernet key for token encryption + mcp_client_id: MCP server OAuth client ID for audience validation """ self.storage = token_storage self.oidc_discovery_url = oidc_discovery_url or os.getenv( @@ -56,6 +58,7 @@ class ProgressiveConsentTokenVerifier: ) self.nextcloud_host = nextcloud_host or os.getenv("NEXTCLOUD_HOST") self.encryption_key = encryption_key or os.getenv("TOKEN_ENCRYPTION_KEY") + self.mcp_client_id = mcp_client_id or os.getenv("OIDC_CLIENT_ID") # Create token broker if not provided if token_broker: @@ -73,10 +76,10 @@ class ProgressiveConsentTokenVerifier: async def verify_token(self, token: str) -> Optional[AccessToken]: """ - Verify a Flow 1 token (aud: "mcp-server"). + Verify a Flow 1 token (aud: ). This validates that: - 1. Token has correct audience for MCP server + 1. Token has correct audience for MCP server (matches client ID) 2. Token is not expired 3. Token has valid signature (if verification enabled) @@ -96,9 +99,11 @@ class ProgressiveConsentTokenVerifier: if isinstance(audiences, str): audiences = [audiences] - # Check for correct audience - if "mcp-server" not in audiences: - logger.warning(f"Token rejected: wrong audience {audiences}") + # Check for correct audience (must match MCP server client ID) + if self.mcp_client_id not in audiences: + logger.warning( + f"Token rejected: wrong audience {audiences}, expected {self.mcp_client_id}" + ) # Check if this is a Nextcloud token (wrong flow) if "nextcloud" in audiences: logger.error( @@ -125,7 +130,7 @@ class ProgressiveConsentTokenVerifier: client_id=client_id, scopes=scopes, expires_at=exp, - resource=f"user:{user_id}", # Store user_id in resource field + resource=user_id, # Store user_id in resource field (RFC 8707) ) except jwt.InvalidTokenError as e: diff --git a/nextcloud_mcp_server/auth/provisioning_decorator.py b/nextcloud_mcp_server/auth/provisioning_decorator.py index 125539b..e00c04f 100644 --- a/nextcloud_mcp_server/auth/provisioning_decorator.py +++ b/nextcloud_mcp_server/auth/provisioning_decorator.py @@ -63,7 +63,17 @@ def require_provisioning(func: Callable) -> Callable: logger.debug("BasicAuth mode detected - skipping provisioning check") return await func(*args, **kwargs) - # Progressive Consent mode - check if user has completed Flow 2 provisioning + # Check if we're in token exchange mode - if so, skip provisioning check + # In token exchange mode, tokens are exchanged per-request (no stored refresh tokens) + from nextcloud_mcp_server.config import get_settings + + settings = get_settings() + if hasattr(lifespan_ctx, "nextcloud_host") and settings.enable_token_exchange: + # Token exchange mode - per-request exchange, no provisioning needed + logger.debug("Token exchange mode detected - skipping provisioning check") + return await func(*args, **kwargs) + + # Progressive Consent mode (offline access) - check if user has completed Flow 2 provisioning # Get user_id from authorization token user_id = None if hasattr(ctx, "authorization") and ctx.authorization: diff --git a/nextcloud_mcp_server/auth/token_exchange.py b/nextcloud_mcp_server/auth/token_exchange.py index 3afd2b5..54aa344 100644 --- a/nextcloud_mcp_server/auth/token_exchange.py +++ b/nextcloud_mcp_server/auth/token_exchange.py @@ -28,6 +28,9 @@ logger = logging.getLogger(__name__) class TokenExchangeService: """Implements RFC 8693 OAuth 2.0 Token Exchange.""" + # RFC 8693 Grant Type + TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange" + # RFC 8693 Token Type Identifiers TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token" TOKEN_TYPE_JWT = "urn:ietf:params:oauth:token-type:jwt" @@ -60,8 +63,9 @@ class TokenExchangeService: self._discovery_cache_time: float = 0 self._discovery_cache_ttl: float = 3600 # 1 hour - # Initialize storage for checking provisioning - self.storage = RefreshTokenStorage() + # Storage for Progressive Consent (refresh tokens) - only needed for delegation + # NOT needed for pure RFC 8693 exchange (MCP tools) + self.storage: Optional[RefreshTokenStorage] = None # Create HTTP client self.http_client = httpx.AsyncClient( @@ -71,7 +75,8 @@ class TokenExchangeService: async def __aenter__(self): """Async context manager entry.""" - await self.storage.initialize() + if self.storage: + await self.storage.initialize() return self async def __aexit__(self, exc_type, exc_val, exc_tb): @@ -83,6 +88,16 @@ class TokenExchangeService: await self.http_client.aclose() # RefreshTokenStorage doesn't have a close method + async def _ensure_storage(self): + """Lazily initialize storage for Progressive Consent operations. + + Only needed for delegation operations that use refresh tokens. + NOT needed for pure RFC 8693 exchange (MCP tools). + """ + if self.storage is None: + self.storage = RefreshTokenStorage.from_env() + await self.storage.initialize() + async def _discover_endpoints(self) -> Dict[str, Any]: """Discover OIDC endpoints from discovery URL. @@ -178,9 +193,104 @@ class TokenExchangeService: return delegated_token, expires_in + async def exchange_token_for_audience( + self, + subject_token: str, + requested_audience: str = "nextcloud", + requested_scopes: list[str] | None = None, + ) -> Tuple[str, int]: + """ + Pure RFC 8693 token exchange (no refresh tokens required). + + This implements stateless per-request token exchange where: + 1. Client token has aud: (e.g., "nextcloud-mcp-server") + 2. Exchange for token with aud: "nextcloud" (for API access) + 3. NO refresh tokens or provisioning required + + Use case: All MCP tool calls (request-time operations). + NOT for background jobs (which use refresh tokens separately). + + Args: + subject_token: Token being exchanged (from MCP client) + requested_audience: Target audience (usually "nextcloud") + requested_scopes: Optional scopes (may not be supported by all IdPs) + + Returns: + Tuple of (access_token, expires_in) + + Raises: + ValueError: If token validation fails + RuntimeError: If exchange fails + """ + # 1. Validate subject token (accepts both "mcp-server" and client_id) + await self._validate_flow1_token(subject_token) + + # 2. Extract user ID for logging + user_id = self._extract_user_id(subject_token) + + # 3. Discover token endpoint + discovery = await self._discover_endpoints() + token_endpoint = discovery.get("token_endpoint") + + if not token_endpoint: + raise RuntimeError("No token endpoint found in discovery") + + # 4. Build pure RFC 8693 exchange request (subject_token ONLY) + data = { + "grant_type": self.TOKEN_EXCHANGE_GRANT, + "subject_token": subject_token, + "subject_token_type": self.TOKEN_TYPE_ACCESS_TOKEN, + "requested_token_type": self.TOKEN_TYPE_ACCESS_TOKEN, + "audience": requested_audience, + } + + # Add scopes if provided (may not be supported by all providers) + if requested_scopes: + data["scope"] = " ".join(requested_scopes) + + # Add client credentials + if self.client_id and self.client_secret: + data["client_id"] = self.client_id + data["client_secret"] = self.client_secret + + try: + # Perform exchange + logger.debug(f"Exchanging token for audience={requested_audience}") + response = await self.http_client.post( + token_endpoint, + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + response.raise_for_status() + result = response.json() + + access_token = result.get("access_token") + expires_in = result.get("expires_in", 300) + + if not access_token: + raise RuntimeError("No access token in exchange response") + + logger.info( + f"Pure RFC 8693 token exchange successful for user {user_id}: " + f"audience={requested_audience}, expires_in={expires_in}s" + ) + + return access_token, expires_in + + except httpx.HTTPStatusError as e: + logger.error(f"Token exchange failed: {e.response.text}") + raise RuntimeError(f"Token exchange failed: {e}") + except Exception as e: + logger.error(f"Token exchange error: {e}") + raise + async def _validate_flow1_token(self, token: str): """Validate that token has correct audience for MCP server. + Accepts either: + - "mcp-server" (Progressive Consent legacy) + - self.client_id (external IdP, e.g., "nextcloud-mcp-server") + Args: token: JWT token to validate @@ -197,9 +307,14 @@ class TokenExchangeService: if isinstance(audience, str): audience = [audience] - if "mcp-server" not in audience: + # Accept either "mcp-server" (Progressive Consent) or client_id (external IdP) + valid_audiences = ["mcp-server"] + if self.client_id: + valid_audiences.append(self.client_id) + + if not any(aud in audience for aud in valid_audiences): raise ValueError( - f"Invalid token audience. Expected 'mcp-server', got {audience}" + f"Invalid token audience. Expected one of {valid_audiences}, got {audience}" ) # Check expiration @@ -247,6 +362,7 @@ class TokenExchangeService: Returns: True if provisioned, False otherwise """ + await self._ensure_storage() token_data = await self.storage.get_refresh_token(user_id) return token_data is not None @@ -259,6 +375,7 @@ class TokenExchangeService: Returns: Refresh token if found, None otherwise """ + await self._ensure_storage() token_data = await self.storage.get_refresh_token(user_id) if token_data: return token_data.get("refresh_token") @@ -412,6 +529,9 @@ _token_exchange_service: Optional[TokenExchangeService] = None async def get_token_exchange_service() -> TokenExchangeService: """Get or create the singleton token exchange service. + Note: Storage is initialized lazily only when needed for delegation operations. + Pure RFC 8693 exchange (MCP tools) doesn't require storage. + Returns: TokenExchangeService instance """ @@ -419,7 +539,7 @@ async def get_token_exchange_service() -> TokenExchangeService: if _token_exchange_service is None: _token_exchange_service = TokenExchangeService() - await _token_exchange_service.storage.initialize() + # Storage is initialized lazily via _ensure_storage() when needed return _token_exchange_service @@ -427,7 +547,9 @@ async def get_token_exchange_service() -> TokenExchangeService: async def exchange_token_for_delegation( flow1_token: str, requested_scopes: list[str], requested_audience: str = "nextcloud" ) -> Tuple[str, int]: - """Convenience function to exchange tokens. + """Convenience function to exchange tokens (Progressive Consent with refresh tokens). + + NOTE: This is for background jobs only. For MCP tool calls, use exchange_token_for_audience(). Args: flow1_token: The MCP session token (aud: "mcp-server") @@ -443,3 +565,28 @@ async def exchange_token_for_delegation( requested_scopes=requested_scopes, requested_audience=requested_audience, ) + + +async def exchange_token_for_audience( + subject_token: str, + requested_audience: str = "nextcloud", + requested_scopes: list[str] | None = None, +) -> Tuple[str, int]: + """Convenience function for pure RFC 8693 token exchange (no refresh tokens). + + Use this for ALL MCP tool calls (request-time operations). + + Args: + subject_token: Token being exchanged (from MCP client) + requested_audience: Target audience (usually "nextcloud") + requested_scopes: Optional scopes (may not be supported by all IdPs) + + Returns: + Tuple of (access_token, expires_in) + """ + service = await get_token_exchange_service() + return await service.exchange_token_for_audience( + subject_token=subject_token, + requested_audience=requested_audience, + requested_scopes=requested_scopes, + ) diff --git a/nextcloud_mcp_server/server/notes.py b/nextcloud_mcp_server/server/notes.py index acfe10b..d4080dd 100644 --- a/nextcloud_mcp_server/server/notes.py +++ b/nextcloud_mcp_server/server/notes.py @@ -6,7 +6,6 @@ from mcp.shared.exceptions import McpError from mcp.types import ErrorData from nextcloud_mcp_server.auth import require_scopes -from nextcloud_mcp_server.auth.provisioning_decorator import require_provisioning from nextcloud_mcp_server.context import get_client from nextcloud_mcp_server.models.notes import ( AppendContentResponse, @@ -87,7 +86,6 @@ def configure_notes_tools(mcp: FastMCP): @mcp.tool() @require_scopes("notes:write") - @require_provisioning async def nc_notes_create_note( title: str, content: str, category: str, ctx: Context ) -> CreateNoteResponse: @@ -249,7 +247,6 @@ def configure_notes_tools(mcp: FastMCP): @mcp.tool() @require_scopes("notes:read") - @require_provisioning async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse: """Search notes by title or content, returning only id, title, and category (requires notes:read scope).""" client = await get_client(ctx)