fix: Simplify token verifier to be RFC 7519 compliant

Per RFC 7519 Section 4.1.3, resource servers should only validate their
own presence in the audience claim, not check for other resource servers.

Changes:
- UnifiedTokenVerifier now validates only MCP audience (not Nextcloud's)
- Nextcloud independently validates its own audience when receiving API calls
- This is NOT token passthrough (we validate tokens before use)
- This IS token reuse which is explicitly allowed by RFC 8707

Updates:
- Simplified _validate_multi_audience() to follow OAuth spec
- Updated docstrings and comments to clarify RFC 7519 compliance
- Fixed unit tests that expected dual-audience validation
- Updated ADR-005 to document the correct OAuth interpretation
- All tests pass: unit (65), smoke (5), OAuth integration

This makes the implementation simpler, more maintainable, and properly
aligned with OAuth 2.0 specifications while maintaining security.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-11-05 21:44:04 +01:00
parent 877c4c91e0
commit 7d9ab5559c
6 changed files with 726 additions and 54 deletions
+26 -17
View File
@@ -65,14 +65,15 @@ Based on analysis of the existing code and python-sdk constraints, we will:
### Mode 1: Multi-Audience Token Validation (Default)
Accept tokens that include **both** the MCP server and Nextcloud resource URIs in their audience claims. This is the default mode when `ENABLE_TOKEN_EXCHANGE` is false or not set.
Use multi-audience tokens directly. Per RFC 7519 Section 4.1.3, the MCP server validates only its own presence in the audience claim. Nextcloud independently validates its own audience when receiving API calls. This is the default mode when `ENABLE_TOKEN_EXCHANGE` is false or not set.
**Requirements**:
- Token must have `aud` claim containing valid audiences for:
- Token must have `aud` claim containing:
- **MCP server**: Client ID OR MCP server URL (e.g., `http://localhost:8000`)
- For Nextcloud API access to work, token should also include:
- **Nextcloud**: Nextcloud resource URI (e.g., `http://localhost:8080`)
- Single token works for both MCP authentication and Nextcloud API access
- IdP must support multi-audience tokens
- IdP must support multi-audience tokens for full functionality
**Resource URI Configuration**:
- Nextcloud OIDC app: Set via `default_resource_identifier` (default: `http://localhost:8080`)
@@ -94,33 +95,39 @@ NEXTCLOUD_RESOURCE_URI=http://localhost:8080 # Nextcloud resource identifier
OIDC_CLIENT_ID=nextcloud-mcp-server
```
**Token validation logic**:
**Token validation logic (RFC 7519 compliant)**:
```python
async def validate_token_audiences(token: dict, settings: Settings) -> bool:
"""Validate token has required audiences for both MCP and Nextcloud."""
"""
Validate token has MCP audience per RFC 7519.
Resource servers validate only their own presence in the audience claim.
Nextcloud will independently validate its own audience when receiving API calls.
This is NOT token passthrough (we validate the token). This IS token reuse
which is allowed by RFC 8707 for multi-audience tokens between trusted services.
"""
audiences = token.get("aud", [])
if isinstance(audiences, str):
audiences = [audiences]
audiences_set = set(audiences)
# MCP must have at least one: client_id OR server_url
# MCP validates ONLY its own audience (client_id OR server_url OR server_url/mcp)
mcp_valid = (
settings.oidc_client_id in audiences_set or
settings.nextcloud_mcp_server_url in audiences_set
settings.nextcloud_mcp_server_url in audiences_set or
f"{settings.nextcloud_mcp_server_url}/mcp" in audiences_set
)
# Nextcloud must have its resource URI
nextcloud_valid = settings.nextcloud_resource_uri in audiences_set
if not (mcp_valid and nextcloud_valid):
if not mcp_valid:
logger.error(
f"Token rejected: Invalid audiences. "
f"Token rejected: Missing MCP audience. "
f"Got {audiences}, need MCP ({settings.oidc_client_id} or "
f"{settings.nextcloud_mcp_server_url}) AND Nextcloud ({settings.nextcloud_resource_uri})"
f"{settings.nextcloud_mcp_server_url})"
)
return False
# Note: We do NOT validate Nextcloud's audience - that's Nextcloud's responsibility
return True
```
@@ -894,10 +901,12 @@ verifier = UnifiedTokenVerifier(settings)
### Positive
1. **Security Compliance**: Eliminates token passthrough vulnerability
2. **Clear Architecture**: Explicit validation modes with resource URI semantics
3. **Performance**: Negligible impact in LLM context (1-2% of request time)
4. **Flexibility**: Supports both simple (multi-audience) and strict (exchange) modes
5. **Audit Trail**: Proper audience separation enables accurate logging
2. **OAuth Spec Compliance**: Follows RFC 7519 Section 4.1.3 - resource servers validate only their own audience
3. **Clear Architecture**: Explicit validation modes with resource URI semantics
4. **Performance**: Negligible impact in LLM context (1-2% of request time)
5. **Flexibility**: Supports both simple (multi-audience) and strict (exchange) modes
6. **Audit Trail**: Proper audience separation enables accurate logging
7. **Simpler Logic**: Each resource server independently validates its own audience, reducing complexity
### Negative
@@ -0,0 +1,651 @@
# ADR-006: Progressive Consent via URL Elicitation (SEP-1036)
**Status**: Proposed
**Date**: 2025-01-05
**Related**: [SEP-1036](https://github.com/modelcontextprotocol/specification/pull/887), ADR-004
**Depends On**: ADR-005 (token validation)
## Context
The current progressive consent implementation (ADR-004) requires users to manually visit OAuth URLs returned by MCP tools. This creates a poor user experience:
1. User calls `provision_nextcloud_access` tool
2. Tool returns a URL as text in the response
3. User must manually copy URL and open in browser
4. No indication when provisioning is complete
5. User must retry the original operation manually
### SEP-1036: URL Mode Elicitation
The MCP specification now supports **URL mode elicitation** ([SEP-1036](https://github.com/modelcontextprotocol/specification/pull/887)), which enables servers to:
- Request out-of-band user interactions via secure URLs
- Handle sensitive operations like OAuth flows without exposing credentials to the client
- Provide progress tracking for async operations
- Return errors that automatically trigger elicitation flows
**Key benefits for progressive consent**:
- **Automatic URL Opening**: Client opens URL in browser automatically (with user consent)
- **Progress Tracking**: Server can notify client when provisioning is complete
- **Error-Triggered Flows**: Server can return `ElicitationRequired` error to trigger provisioning
- **Better UX**: User doesn't manually copy/paste URLs
### Current Implementation Limitations
The current progressive consent flow in `nextcloud_mcp_server/server/oauth_tools.py`:
```python
@mcp.tool(name="provision_nextcloud_access")
async def tool_provision_access(ctx: Context) -> ProvisioningResult:
"""Returns OAuth URL as text - user must manually open it."""
return ProvisioningResult(
success=True,
authorization_url=auth_url, # User must copy this
message="Please visit the authorization URL..."
)
```
**Problems**:
1. Manual URL handling (copy/paste)
2. No progress tracking
3. No automatic retry after provisioning
4. Tool call required just to get URL
5. No client integration (URL just displayed as text)
## Decision
We will **migrate progressive consent from manual tools to URL mode elicitation**, leveraging SEP-1036 for better user experience and OAuth security.
### New Architecture: Elicitation-Driven Consent
Instead of explicit tools, use **automatic elicitation** triggered by authorization errors:
```
User → Calls Nextcloud Tool → Server Checks Provisioning
↓ Not Provisioned
Error: ElicitationRequired
Client Shows Consent UI
↓ User Accepts
Client Opens OAuth URL
User Completes OAuth
Server Sends Progress Update
Original Tool Call Auto-Retries
```
### Mode 1: Elicitation-Required Error (Primary)
When a tool requires provisioning, return an **ElicitationRequired error** (-32000):
```python
# In any Nextcloud tool decorated with @require_provisioning
@mcp.tool()
@require_provisioning # New decorator
async def nc_notes_list_notes(ctx: Context):
"""List notes - auto-triggers provisioning if needed."""
# If not provisioned, decorator returns ElicitationRequired error
# If provisioned, continues normally
client = await get_client(ctx)
return await client.notes.list_notes()
```
**Error response structure**:
```json
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32000,
"message": "Nextcloud access provisioning required",
"data": {
"elicitations": [
{
"mode": "url",
"elicitationId": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://mcp.example.com/oauth/provision?id=550e8400...",
"message": "Grant the MCP server access to your Nextcloud account to continue."
}
]
}
}
}
```
**Client behavior**:
1. Receives error with elicitation
2. Shows consent UI: "App wants to access Nextcloud. Open authorization page?"
3. On user acceptance, opens URL in browser
4. Optionally tracks progress via `elicitation/track`
5. Auto-retries original tool call when complete
### Mode 2: Explicit Elicitation Request (Fallback)
For clients that don't support error-triggered elicitation, provide explicit tool:
```python
@mcp.tool(name="request_nextcloud_access")
async def request_access(ctx: Context) -> ElicitationResponse:
"""Explicitly request provisioning via elicitation."""
# Send elicitation/create request
return await create_elicitation(
mode="url",
url=generate_oauth_url(),
message="Grant access to Nextcloud",
elicitation_id=generate_id()
)
```
**Note**: This is a fallback for compatibility. Primary flow uses error-triggered elicitation.
## Implementation
### 1. New Decorator: `@require_provisioning`
Replace explicit provisioning checks with a decorator that returns `ElicitationRequired`:
```python
# nextcloud_mcp_server/auth/provisioning_decorator.py
def require_provisioning(func):
"""
Decorator that ensures user has provisioned Nextcloud access.
If not provisioned, returns ElicitationRequired error with OAuth URL.
Otherwise, proceeds with normal tool execution.
"""
@functools.wraps(func)
async def wrapper(ctx: Context, *args, **kwargs):
# Extract user ID from token
user_id = get_user_id_from_context(ctx)
# Check if provisioned
storage = RefreshTokenStorage.from_env()
await storage.initialize()
if not await storage.has_refresh_token(user_id):
# Not provisioned - return ElicitationRequired error
elicitation_id = str(uuid.uuid4())
oauth_url = await generate_oauth_url_for_provisioning(
user_id=user_id,
elicitation_id=elicitation_id,
ctx=ctx
)
# Store elicitation for tracking
await storage.store_elicitation(
elicitation_id=elicitation_id,
user_id=user_id,
status="pending",
created_at=datetime.now(timezone.utc)
)
raise McpError(
code=ErrorCode.ELICITATION_REQUIRED, # -32000
message="Nextcloud access provisioning required",
data={
"elicitations": [
{
"mode": "url",
"elicitationId": elicitation_id,
"url": oauth_url,
"message": (
"Grant the MCP server access to your Nextcloud "
"account to continue. This is a one-time setup."
)
}
]
}
)
# Already provisioned - proceed normally
return await func(ctx, *args, **kwargs)
return wrapper
```
### 2. Elicitation Tracking Endpoint
Implement `elicitation/track` to provide progress updates:
```python
# nextcloud_mcp_server/server/elicitation.py
@mcp.request_handler("elicitation/track")
async def track_elicitation(
elicitation_id: str,
_meta: dict = None
) -> dict:
"""
Track progress of an elicitation request.
Returns when elicitation is complete or times out.
"""
progress_token = _meta.get("progressToken") if _meta else None
storage = RefreshTokenStorage.from_env()
await storage.initialize()
# Poll for completion (with timeout)
timeout = 300 # 5 minutes
start_time = datetime.now(timezone.utc)
while (datetime.now(timezone.utc) - start_time).seconds < timeout:
elicitation = await storage.get_elicitation(elicitation_id)
if not elicitation:
raise McpError(
code=-32602, # Invalid params
message=f"Unknown elicitation ID: {elicitation_id}"
)
# Send progress notification if token provided
if progress_token and elicitation["status"] == "pending":
await send_progress_notification(
progress_token=progress_token,
progress=50,
message="Waiting for OAuth authorization..."
)
# Check if complete
if elicitation["status"] == "complete":
return {"status": "complete"}
# Check if failed
if elicitation["status"] == "failed":
return {
"status": "failed",
"error": elicitation.get("error_message")
}
# Wait before polling again
await asyncio.sleep(2)
# Timeout
raise McpError(
code=-32000,
message="Elicitation timed out - user did not complete authorization"
)
```
### 3. OAuth Callback Updates
Update the OAuth callback to mark elicitations as complete:
```python
# nextcloud_mcp_server/auth/oauth_routes.py
async def oauth_callback(request: Request) -> Response:
"""Handle OAuth callback and mark elicitation complete."""
code = request.query_params.get("code")
state = request.query_params.get("state")
# Validate and exchange code for tokens
tokens = await exchange_authorization_code(code)
# Store refresh token
await storage.store_refresh_token(
user_id=user_id,
refresh_token=tokens["refresh_token"]
)
# Mark elicitation as complete
elicitation_id = request.query_params.get("elicitation_id")
if elicitation_id:
await storage.update_elicitation(
elicitation_id=elicitation_id,
status="complete",
completed_at=datetime.now(timezone.utc)
)
return Response(
content="<h1>Authorization Complete!</h1>"
"<p>You can close this window and return to the application.</p>",
media_type="text/html"
)
```
### 4. Update All Nextcloud Tools
Add `@require_provisioning` decorator to all Nextcloud tools:
```python
# nextcloud_mcp_server/server/notes.py
@mcp.tool()
@require_scopes("notes:read")
@require_provisioning # NEW: Auto-triggers provisioning
async def nc_notes_list_notes(
ctx: Context,
category: Optional[str] = None
) -> NotesListResponse:
"""List all notes - automatically handles provisioning."""
client = await get_client(ctx)
# Tool logic proceeds only if provisioned
notes = await client.notes.list_notes(category=category)
return NotesListResponse(results=notes)
```
### 5. Capability Declaration
Declare URL elicitation support during initialization:
```python
# nextcloud_mcp_server/app.py
capabilities = {
"elicitation": {
"url": {} # Declare URL mode support
# Note: We don't support "form" mode (in-band data collection)
},
# ... other capabilities
}
```
### 6. Environment Variables
**New variables**:
```bash
# ELICITATION_CALLBACK_URL: Base URL for OAuth callbacks with elicitation tracking
# Default: NEXTCLOUD_MCP_SERVER_URL + /oauth/callback
ELICITATION_CALLBACK_URL=http://localhost:8000/oauth/callback
# ELICITATION_TIMEOUT_SECONDS: How long to wait for user to complete OAuth
# Default: 300 (5 minutes)
ELICITATION_TIMEOUT_SECONDS=300
```
**Removed variables** (no longer needed):
```bash
# ENABLE_PROGRESSIVE_CONSENT - removed, now always enabled in OAuth mode
# MCP_SERVER_CLIENT_ID - merged into OIDC_CLIENT_ID
```
## User Experience Comparison
### Before (ADR-004 Manual Tools)
```
User: "List my notes"
Assistant: *calls nc_notes_list_notes*
Server: Error - not provisioned
Assistant: "You need to provision access first. Let me do that."
Assistant: *calls provision_nextcloud_access*
Server: {authorization_url: "https://..."}
Assistant: "Please visit this URL: https://..."
User: *copies URL, opens browser, completes OAuth*
User: "OK, I'm done"
Assistant: *calls nc_notes_list_notes again*
Server: Success! [notes...]
```
**Issues**: 4 interactions, manual URL handling, no automation
### After (ADR-006 Elicitation)
```
User: "List my notes"
Assistant: *calls nc_notes_list_notes*
Server: ElicitationRequired error
Client: Shows dialog: "Grant access to Nextcloud? [Yes] [No]"
User: *clicks Yes*
Client: Opens OAuth URL in browser automatically
User: *completes OAuth*
Server: Sends progress notification "Complete!"
Client: Auto-retries nc_notes_list_notes
Server: Success! [notes...]
Assistant: "Here are your notes: ..."
```
**Benefits**: 1 interaction, automatic URL opening, seamless retry
## Migration Path
### Phase 1: Add Elicitation Support (v0.26.0)
- Implement `@require_provisioning` decorator
- Add `elicitation/track` endpoint
- Keep existing tools (`provision_nextcloud_access`) for compatibility
- Update OAuth callback to track elicitations
- Add capability declaration
**Breaking changes**: None (additive)
### Phase 2: Update Documentation (v0.27.0)
- Document elicitation-based flow as primary
- Mark manual tools as deprecated
- Update examples and guides
**Breaking changes**: None (documentation only)
### Phase 3: Remove Manual Tools (v0.28.0)
- Remove `provision_nextcloud_access` tool
- Remove `check_provisioning_status` tool (status in error message)
- Remove `revoke_nextcloud_access` (or keep for explicit revocation?)
**Breaking changes**: Yes (removed tools)
### Phase 4: Optimize (v0.29.0+)
- Add elicitation result caching
- Implement retry strategies
- Add metrics and monitoring
## Testing
### Test Cases
1. **First-Time User Flow**
```python
@pytest.mark.oauth
async def test_elicitation_first_time_user(nc_mcp_oauth_client):
"""Test that first tool call triggers elicitation."""
# User has no provisioning
with pytest.raises(McpError) as exc:
await nc_mcp_oauth_client.call_tool("nc_notes_list_notes")
# Should get ElicitationRequired error
assert exc.value.code == -32000
assert "elicitations" in exc.value.data
assert exc.value.data["elicitations"][0]["mode"] == "url"
# Verify URL is valid OAuth URL
url = exc.value.data["elicitations"][0]["url"]
assert "oauth" in url
assert "elicitationId" in url
```
2. **Progress Tracking**
```python
@pytest.mark.oauth
async def test_elicitation_progress_tracking(nc_mcp_oauth_client):
"""Test progress tracking during OAuth flow."""
# Trigger elicitation
elicitation_id = trigger_elicitation()
# Start tracking
track_task = asyncio.create_task(
nc_mcp_oauth_client.track_elicitation(
elicitation_id=elicitation_id,
progress_token="test-token"
)
)
# Simulate OAuth completion
await asyncio.sleep(1)
await complete_oauth_flow(elicitation_id)
# Track should complete
result = await track_task
assert result["status"] == "complete"
```
3. **Auto-Retry After Provisioning**
```python
@pytest.mark.oauth
async def test_auto_retry_after_provisioning(nc_mcp_oauth_client):
"""Test that client auto-retries after elicitation."""
# Mock client that auto-retries on ElicitationRequired
client = AutoRetryMcpClient(nc_mcp_oauth_client)
# First call triggers elicitation, client handles it, retries
result = await client.call_tool_with_elicitation("nc_notes_list_notes")
# Should succeed after provisioning
assert result.success
assert "notes" in result.data
```
4. **Timeout Handling**
```python
@pytest.mark.oauth
async def test_elicitation_timeout(nc_mcp_oauth_client):
"""Test timeout if user doesn't complete OAuth."""
elicitation_id = trigger_elicitation()
# Track with short timeout
with pytest.raises(McpError, match="timed out"):
await nc_mcp_oauth_client.track_elicitation(
elicitation_id=elicitation_id,
timeout=5 # 5 seconds
)
```
## Security Considerations
### Out-of-Band OAuth Flow
**Benefit**: OAuth credentials never pass through MCP client
- User enters credentials directly on IdP page
- MCP server receives only authorization code
- Client never sees passwords or refresh tokens
**Threat mitigation**:
- **Credential theft**: Client can't intercept credentials (out-of-band)
- **Token exposure**: Client never receives Nextcloud refresh tokens
- **CSRF**: State parameter validates OAuth callback
- **URL tampering**: Elicitation ID ties OAuth flow to user session
### Elicitation ID as Security Token
The `elicitationId` serves as a capability token:
- Cryptographically random (UUID v4)
- Single-use (invalidated after completion)
- Time-limited (expires after timeout)
- User-scoped (tied to user session)
**Validation**:
```python
async def validate_elicitation_id(elicitation_id: str, user_id: str) -> bool:
"""Validate that elicitation belongs to user and is still valid."""
elicitation = await storage.get_elicitation(elicitation_id)
if not elicitation:
return False
# Check ownership
if elicitation["user_id"] != user_id:
logger.warning(f"Elicitation ID mismatch: {elicitation_id}")
return False
# Check expiry
if elicitation["expires_at"] < datetime.now(timezone.utc):
return False
# Check not already used
if elicitation["status"] != "pending":
return False
return True
```
### Progress Tracking Security
**Risk**: Progress token reuse across users
**Mitigation**:
- Progress tokens tied to elicitation ID
- Elicitation ID tied to user session
- Server validates ownership before sending updates
## Consequences
### Positive
1. **Better UX**: Automatic URL opening, no manual copy/paste
2. **Seamless Flow**: Auto-retry after provisioning
3. **Progress Feedback**: User knows when OAuth is complete
4. **Spec Compliance**: Implements SEP-1036 correctly
5. **Secure by Design**: Out-of-band OAuth prevents credential exposure
6. **Simpler API**: No explicit provisioning tools needed
### Negative
1. **Client Dependency**: Requires client support for URL elicitation
2. **Complexity**: More moving parts (elicitation tracking, callbacks)
3. **Polling**: Progress tracking uses polling (not ideal)
4. **Breaking Change**: Removes manual provisioning tools (in v0.28.0)
### Neutral
1. **Storage Requirements**: Need to store elicitation state
2. **Timeout Management**: Must handle long-running OAuth flows
3. **Fallback Support**: Still need compatibility for older clients
## Alternatives Considered
### 1. Keep Manual Tools Only (Rejected)
**Pros**: Simple, no client changes needed
**Cons**: Poor UX, doesn't leverage SEP-1036
**Rejection reason**: SEP-1036 provides better UX and security
### 2. Form Mode Elicitation (Rejected)
**Pros**: No browser redirect needed
**Cons**: Would expose OAuth credentials to client (security violation)
**Rejection reason**: Form mode only for non-sensitive data per SEP-1036
### 3. Hybrid: Both Tools and Elicitation (Considered)
**Pros**: Maximum compatibility, gradual migration
**Cons**: API duplication, maintenance burden, confusing for users
**Decision**: Support during migration (v0.26-0.27), remove in v0.28
### 4. WebSocket for Progress (Rejected)
**Pros**: Real-time updates instead of polling
**Cons**: MCP spec uses polling pattern, adds complexity
**Rejection reason**: Follow spec pattern (polling via elicitation/track)
## References
- [SEP-1036: URL Mode Elicitation](https://github.com/modelcontextprotocol/specification/pull/887)
- [MCP Elicitation Specification](https://modelcontextprotocol.io/specification/draft/client/elicitation)
- [ADR-004: Federated Authentication Architecture](./ADR-004-mcp-application-oauth.md)
- [ADR-005: Token Audience Validation](./ADR-005-token-audience-validation.md)
- [RFC 8252: OAuth 2.0 for Native Apps](https://datatracker.ietf.org/doc/html/rfc8252)
## Implementation Checklist
- [ ] Implement `@require_provisioning` decorator with ElicitationRequired error
- [ ] Add `elicitation/track` request handler
- [ ] Update OAuth callback to mark elicitations complete
- [ ] Add elicitation storage (ID, user, status, timestamps)
- [ ] Update all Nextcloud tools with `@require_provisioning`
- [ ] Add URL elicitation capability declaration
- [ ] Write integration tests for elicitation flow
- [ ] Write tests for progress tracking
- [ ] Update documentation with elicitation examples
- [ ] Add migration guide for manual tools → elicitation
- [ ] Keep manual tools with deprecation warnings (v0.26-0.27)
- [ ] Remove manual tools (v0.28.0)
- [ ] Update CHANGELOG.md with migration timeline
+5 -5
View File
@@ -24,9 +24,9 @@ def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
"""
Create NextcloudClient for multi-audience mode (no exchange needed).
ADR-005 Mode 1: Token already contains both MCP and Nextcloud audiences.
The UnifiedTokenVerifier validated both audiences are present, so we can
use the token directly without exchange.
ADR-005 Mode 1: Use multi-audience tokens directly.
The UnifiedTokenVerifier validated MCP audience per RFC 7519.
Nextcloud will independently validate its own audience.
Args:
ctx: MCP request context containing session info
@@ -65,8 +65,8 @@ def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
f"(no exchange needed)"
)
# Token was already validated to have both audiences
# Can use directly without exchange
# Token was validated to have MCP audience
# Nextcloud will validate its own audience independently
return NextcloudClient.from_token(
base_url=base_url, token=access_token.token, username=username
)
+28 -22
View File
@@ -4,13 +4,15 @@ Unified Token Verifier for ADR-005 Token Audience Validation.
This module replaces both NextcloudTokenVerifier and ProgressiveConsentTokenVerifier
with a single implementation that supports two compliant OAuth modes:
1. Multi-audience mode (default): Tokens must contain BOTH MCP and Nextcloud audiences
1. Multi-audience mode (default): Validates MCP audience per RFC 7519 (resource servers
validate only their own audience). Nextcloud independently validates its own audience.
2. Token exchange mode (opt-in): Tokens have MCP audience only, exchanged for Nextcloud tokens
Key Design Principles:
- Token verification happens HERE (validates audiences)
- Token verification happens HERE (validates MCP audience per OAuth spec)
- Token exchange happens in context_helper.py (when creating NextcloudClient)
- No token passthrough allowed (complies with MCP Security Specification)
- Token reuse IS allowed for multi-audience tokens (RFC 8707)
"""
import hashlib
@@ -39,8 +41,9 @@ class UnifiedTokenVerifier(TokenVerifier):
3. Caches successful validations to avoid repeated API calls
Mode Selection (via ENABLE_TOKEN_EXCHANGE setting):
- False/omit (default): Multi-audience mode - requires BOTH MCP and Nextcloud audiences
- True: Exchange mode - requires MCP audience only (exchange happens later)
- False/omit (default): Multi-audience mode - validates MCP audience only (per RFC 7519).
Nextcloud independently validates its own audience when receiving API calls.
- True: Exchange mode - requires MCP audience only, then exchanges for Nextcloud token
"""
def __init__(self, settings: Settings):
@@ -90,7 +93,7 @@ class UnifiedTokenVerifier(TokenVerifier):
CRITICAL: This method only validates tokens - it does NOT perform exchange.
Token exchange happens later in context_helper.py when creating NextcloudClient.
Multi-audience mode: Validates token has BOTH MCP and Nextcloud audiences
Multi-audience mode: Validates token has MCP audience (per RFC 7519)
Exchange mode: Validates token has MCP audience ONLY (exchange happens later)
Args:
@@ -115,14 +118,17 @@ class UnifiedTokenVerifier(TokenVerifier):
async def _verify_multi_audience_token(self, token: str) -> AccessToken | None:
"""
Validate token has both MCP and Nextcloud audiences (Mode 1).
Validate token has MCP audience (Mode 1).
Token can be used directly without exchange.
Per RFC 7519, we only validate our own (MCP) audience. Nextcloud will
independently validate its own audience when it receives the token.
Args:
token: Bearer token to verify
Returns:
AccessToken if valid with both audiences, None otherwise
AccessToken if valid with MCP audience, None otherwise
"""
try:
# Attempt JWT verification first
@@ -138,19 +144,18 @@ class UnifiedTokenVerifier(TokenVerifier):
if not payload:
return None
# Validate both audiences are present
# Validate MCP audience is present
if not self._validate_multi_audience(payload):
audiences = payload.get("aud", [])
logger.error(
f"Token rejected: Missing required audiences. "
f"Got {audiences}, need both MCP ({self.settings.oidc_client_id} or "
f"{self.settings.nextcloud_mcp_server_url}) AND Nextcloud "
f"({self.settings.nextcloud_resource_uri})"
f"Token rejected: Missing MCP audience. "
f"Got {audiences}, need MCP ({self.settings.oidc_client_id} or "
f"{self.settings.nextcloud_mcp_server_url})"
)
return None
logger.info(
"Multi-audience validation passed - token has both MCP and Nextcloud audiences"
"MCP audience validation passed - token authorized for MCP server"
)
return self._create_access_token(token, payload)
@@ -204,13 +209,20 @@ class UnifiedTokenVerifier(TokenVerifier):
def _validate_multi_audience(self, payload: dict[str, Any]) -> bool:
"""
Check if token has both MCP and Nextcloud audiences.
Check if token has MCP audience.
Per RFC 7519 Section 4.1.3, resource servers should only validate their own
presence in the audience claim. We don't validate Nextcloud's audience - that's
Nextcloud's responsibility when it receives the token.
This is NOT token passthrough (we validate the token). This IS token reuse
which is allowed by RFC 8707 for multi-audience tokens between trusted services.
Args:
payload: Decoded token payload
Returns:
True if both audiences present, False otherwise
True if MCP audience present, False otherwise
"""
audiences = payload.get("aud", [])
if isinstance(audiences, str):
@@ -227,13 +239,7 @@ class UnifiedTokenVerifier(TokenVerifier):
)
)
# Nextcloud must have its resource URI
nextcloud_valid = bool(
self.settings.nextcloud_resource_uri
and self.settings.nextcloud_resource_uri in audiences_set
)
return bool(mcp_valid and nextcloud_valid)
return bool(mcp_valid)
def _has_mcp_audience(self, payload: dict[str, Any]) -> bool:
"""
+2 -1
View File
@@ -66,7 +66,8 @@ async def get_client(ctx: Context) -> NextcloudClient:
)
else:
# Mode 1: Multi-audience token - use directly
# Token was validated to have BOTH audiences in UnifiedTokenVerifier
# Token was validated to have MCP audience in UnifiedTokenVerifier
# Nextcloud will independently validate its own audience when receiving API calls
return get_client_from_context(ctx, lifespan_ctx.nextcloud_host)
# Unknown context type
+14 -9
View File
@@ -94,7 +94,7 @@ class TestAudienceValidation:
assert verifier._validate_multi_audience(payload) is False
def test_validate_multi_audience_missing_nextcloud(self, base_settings):
"""Test multi-audience validation fails without Nextcloud audience."""
"""Test multi-audience validation succeeds with only MCP audience (RFC 7519 compliant)."""
verifier = UnifiedTokenVerifier(base_settings)
payload = {
"aud": ["test-client-id"], # Only MCP
@@ -102,10 +102,11 @@ class TestAudienceValidation:
"exp": int(time.time() + 3600),
}
assert verifier._validate_multi_audience(payload) is False
# Per RFC 7519, we only validate MCP audience. Nextcloud validates its own.
assert verifier._validate_multi_audience(payload) is True
def test_validate_multi_audience_string_audience(self, base_settings):
"""Test multi-audience validation with string audience (should still work)."""
"""Test multi-audience validation with string audience works (RFC 7519 compliant)."""
verifier = UnifiedTokenVerifier(base_settings)
payload = {
"aud": "test-client-id", # Single audience as string
@@ -113,8 +114,8 @@ class TestAudienceValidation:
"exp": int(time.time() + 3600),
}
# Should fail - needs both audiences
assert verifier._validate_multi_audience(payload) is False
# Should pass - we only validate MCP audience per RFC 7519
assert verifier._validate_multi_audience(payload) is True
def test_has_mcp_audience_with_client_id(self, exchange_settings):
"""Test MCP audience validation with client ID."""
@@ -266,14 +267,16 @@ class TestMultiAudienceVerification:
async def test_verify_multi_audience_fails_without_both_audiences(
self, base_settings
):
"""Test multi-audience verification fails without both audiences."""
"""Test multi-audience verification succeeds with only MCP audience (RFC 7519 compliant)."""
verifier = UnifiedTokenVerifier(base_settings)
# Mock introspection response with only one audience
# Mock introspection response with only MCP audience
introspection_response = {
"active": True,
"sub": "testuser",
"aud": ["test-client-id"], # Missing Nextcloud audience
"aud": [
"test-client-id"
], # Only MCP audience (Nextcloud validates its own)
"scope": "openid profile",
"exp": int(time.time() + 3600),
}
@@ -284,7 +287,9 @@ class TestMultiAudienceVerification:
opaque_token = "opaque-token-12345"
result = await verifier._verify_multi_audience_token(opaque_token)
assert result is None
# Should succeed with only MCP audience per RFC 7519
assert result is not None
assert result.resource == "testuser"
class TestExchangeModeVerification: