This commit adds proper integration testing of the login elicitation flow
(ADR-006) using python-sdk's MCP client with actual elicitation callback
support, and fixes user_id extraction to support both JWT and opaque tokens.
## Changes
### 1. Enhanced create_mcp_client_session helper (tests/conftest.py)
- Added `elicitation_callback` parameter to function signature
- Pass callback to ClientSession constructor
- Added necessary imports: RequestContext, ElicitRequestParams,
ElicitResult, ErrorData from mcp package
- Allows fixtures to provide custom elicitation handlers
### 2. New fixture: nc_mcp_oauth_client_with_elicitation (tests/conftest.py)
- Creates MCP client with Playwright-based elicitation callback
- Callback implementation:
- Extracts OAuth URL from elicitation message using regex
- Uses Playwright browser to complete OAuth flow automatically
- Handles Nextcloud login form (username/password)
- Handles consent screen if present
- Waits for OAuth callback completion
- Returns ElicitResult(action="accept") on success
- Function-scoped to allow independent test state
- Tracks elicitation invocations via session.elicitation_triggered
### 3. Fixed user_id extraction for opaque tokens (oauth_tools.py)
- Created extract_user_id_from_token() helper to handle both JWT and
opaque tokens by calling userinfo endpoint when needed
- Fixed check_logged_in to use helper instead of broken ctx.authorization
- Fixed revoke_nextcloud_access to use helper instead of ctx.context.get()
- Both tools now properly extract user_id from access tokens
### 4. Enhanced integration tests (test_elicitation_integration.py)
- Updated tests to revoke refresh tokens via MCP tool
- All 4 tests now pass:
- test_check_logged_in_with_real_elicitation_callback: Complete flow
- test_elicitation_callback_url_extraction: URL extraction validation
- test_elicitation_stores_refresh_token: Token persistence verification
- test_second_check_logged_in_does_not_elicit: No redundant elicitations
### 5. Added diagnostic logging (oauth_routes.py)
- Track user_id extraction from ID tokens during OAuth callbacks
- Log refresh token storage with user_id and flow_type
## Test Results
✅ 4/4 tests pass
The test suite successfully validates:
- Elicitation callback is triggered when no refresh token exists
- Playwright automation completes OAuth flow
- Refresh token is stored after OAuth with correct user_id
- Tool returns "yes" after successful login
- Already-logged-in users don't get redundant elicitations
## Why This Matters
Previous tests (test_login_elicitation.py) only validated response
formats and acknowledged they couldn't test real elicitation protocol.
This test exercises the REAL MCP elicitation protocol end-to-end:
1. MCP server calls ctx.elicit()
2. python-sdk ClientSession invokes custom callback
3. Callback completes OAuth via Playwright
4. Client returns acceptance to server
5. Tool proceeds with authenticated state
This proves the python-sdk MCP client can handle elicitation in
production environments with both JWT and opaque tokens.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit addresses the "Login not detected" issue after completing
OAuth login via elicitation by unifying the session architecture and
adding comprehensive visibility into background session status.
## Changes
### 1. Enhanced check_logged_in with comprehensive logging (oauth_tools.py)
- Added detailed logging at each step of token lookup
- Implemented fallback strategy: first search by provisioning_client_id,
then fall back to user_id lookup
- This allows detection of refresh tokens created via any flow
(elicitation or browser login)
- Log messages include flow_type, provisioned_at, and provisioning_client_id
for debugging
### 2. Unified session architecture (browser_oauth_routes.py)
- Browser login now stores provisioning_client_id=state when saving
refresh token
- This makes browser and elicitation flows consistent - both can be
found by the same state parameter
- Treats Flow 2 (elicitation) and browser login as the same "background
session"
### 3. Enhanced /user/page with session status (userinfo_routes.py)
- Added comprehensive background access section showing:
- Background Access: Granted/Not Granted (with visual indicators)
- Flow Type: browser/flow2/hybrid
- Provisioned At: timestamp
- Token Audience: nextcloud/mcp
- Scopes: detailed scope list
- Status displayed regardless of which flow created the session
(browser login or elicitation)
### 4. Added revoke functionality (userinfo_routes.py, app.py)
- New POST endpoint: /user/revoke
- Allows users to revoke background access (delete refresh token)
- Browser session cookie remains valid for UI access
- Confirmation dialog before revocation
- Success page with auto-redirect back to /user/page
- Registered route in app.py browser_routes
## Testing
All tests pass:
- 6/6 login elicitation tests pass
- 21/21 core OAuth tests pass
- Comprehensive logging helps debug future issues
## Fixes
Resolves: "Login not detected. Please ensure you completed the login
at the provided URL before clicking OK."
The issue occurred because elicitation and browser login created
separate sessions. Now they are unified under the same architecture.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This PR fixes multiple OAuth-related issues:
## Unified OAuth Callback
- Consolidated `/oauth/callback-nextcloud` and `/oauth/login-callback` into single `/oauth/callback` endpoint
- Flow type determined by session lookup via state parameter (no query params in redirect_uri)
- Fixes redirect_uri validation issues with IdPs requiring exact match
- Legacy endpoints kept as aliases for backwards compatibility
## PKCE Implementation
- Implemented PKCE (RFC 7636) for Flow 2 (resource provisioning)
- Generate code_verifier and code_challenge
- Store code_verifier in session storage
- Retrieve and use in token exchange
- Fixed PKCE for browser login (integrated mode)
- Previously only worked for external IdP (Keycloak)
- Now works for both Nextcloud OIDC and external IdP
## Login Elicitation Fixes (ADR-006)
- Fixed elicitation URL to route through MCP server endpoint
- Changed from direct Nextcloud URL to `/oauth/authorize-nextcloud`
- Ensures PKCE is properly handled by server
- Fixed login detection after OAuth flow completes
- Look up refresh token by state parameter instead of user_id
- Works even when Flow 1 token not present
- Added `get_refresh_token_by_provisioning_client_id()` method
## Session Authentication
- Fixed `/user/page` redirect loop
- Shared oauth_context with mounted browser_app
- SessionAuthBackend can now validate sessions correctly
## Tests
- Added comprehensive login elicitation test suite
- Updated scope authorization test expectations
- All 43 OAuth tests passing
## Files Changed
- `app.py`: Shared oauth_context, unified callback route
- `oauth_routes.py`: Unified callback, PKCE for Flow 2
- `browser_oauth_routes.py`: PKCE for integrated mode
- `oauth_tools.py`: Fixed elicitation URL generation
- `refresh_token_storage.py`: Added lookup by provisioning_client_id
- `test_login_elicitation.py`: New test suite
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Add @require_scopes("openid") decorator to OAuth backend tools
(provision_nextcloud_access, revoke_nextcloud_access, check_provisioning_status)
to ensure they're only visible to authenticated OIDC users.
Design rationale:
- OAuth provisioning tools are "meta-tools" that manage authentication itself
- They don't access Nextcloud resources, so don't need resource scopes
- Requiring 'openid' ensures user is authenticated via OIDC
- Enables Progressive Consent: users authenticate first, then provision access
- Aligns with dual OAuth flow architecture (Flow 1 + Flow 2)
Changes:
- Add @require_scopes("openid") to all three OAuth provisioning tools
- Update test expectations: users with only OIDC default scopes
see OAuth provisioning tools but not resource tools
- All tests pass (13/13 in test_scope_authorization.py)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Wire up RFC 8693 token exchange throughout the MCP server to support
stateless per-request token conversion for external IdP scenarios.
Changes:
Authentication Flow:
- Add exchange_token_for_audience() for pure RFC 8693 exchange
- Update context_helper to use stateless token exchange
- Remove fallback to standard OAuth on exchange failure
- Make storage initialization lazy (only for delegation, not MCP tools)
Application Configuration:
- Add ENABLE_TOKEN_EXCHANGE environment variable support
- Skip provisioning tools when token exchange enabled
- Pass mcp_client_id to token broker for proper validation
- Update docker-compose.yml with token exchange config
Token Exchange Service:
- Add TOKEN_EXCHANGE_GRANT constant
- Implement exchange_token_for_audience() method
- Support both "mcp-server" and client_id audiences
- Lazy storage initialization for delegation scenarios
- Enhanced error handling and logging
Progressive Token Verifier:
- Add mcp_client_id parameter for external IdP validation
- Accept both "mcp-server" and configured client_id
- Support external IdP token verification
Key Behavior Changes:
- When ENABLE_TOKEN_EXCHANGE=true: Each MCP tool call triggers
stateless token exchange (client token → Nextcloud token)
- When ENABLE_TOKEN_EXCHANGE=false: Uses pass-through mode
(validates Flow 1 token and passes to Nextcloud)
- No provisioning tools registered in exchange mode
- No refresh tokens needed for request-time operations
This completes the token exchange implementation. The MCP server now
supports both pass-through (default) and exchange (opt-in) modes for
federated authentication architectures.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Resolves the token exchange implementation gap where get_session_client()
was implemented but never used by tools. Unifies token acquisition into a
single async get_client() method that handles both pass-through and token
exchange modes transparently.
Core Changes:
- Make get_client() async and merge token exchange logic into it
- Remove scopes parameter from token exchange (Nextcloud doesn't support OAuth scopes)
- Update all 8 tool modules to use await get_client(ctx)
- Fix provisioning decorator to skip checks in BasicAuth mode
Token Acquisition Modes:
1. BasicAuth: Returns shared client (no token operations)
2. OAuth pass-through (default): Verifies and passes Flow 1 token to Nextcloud
3. OAuth token exchange (opt-in): Exchanges Flow 1 token for ephemeral token via RFC 8693
Key Architectural Clarifications:
- Progressive Consent (Flow 1/2) = Authorization architecture
- Token Exchange = Token acquisition pattern during tool execution
- Refresh tokens from Flow 2 are NEVER used for tool calls (only background jobs)
- Nextcloud scopes are "soft-scopes" enforced by MCP server, not IdP
Documentation Updates:
- ADR-004: Added comprehensive token acquisition patterns section
- CRITICAL-TOKEN-EXCHANGE-PATTERN.md: Updated to reflect implementation status
- CLAUDE.md: Updated architectural patterns with async get_client()
Testing:
- All 36 unit tests passing
- All 4 smoke tests passing (BasicAuth mode)
- Linting issues fixed (ruff)
Configuration:
ENABLE_TOKEN_EXCHANGE=false (default) - pass-through mode
ENABLE_TOKEN_EXCHANGE=true (opt-in) - token exchange mode
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Implements Progressive Consent architecture with dual OAuth flows:
- Flow 1: Direct client authentication (aud: "mcp-server")
- Flow 2: Resource provisioning with refresh tokens
Components added:
- Client registry with validation (client_registry.py)
- Progressive token verifier (progressive_token_verifier.py)
- Token broker service integration
- Provisioning decorator for MCP tools
- OAuth provisioning tools (provision_nextcloud_access, etc.)
Configuration:
- Progressive Consent enabled by default (ENABLE_PROGRESSIVE_CONSENT=true)
- Client validation with pre-registered clients
- Audience separation framework
KNOWN ISSUE - Token Exchange Pattern Incorrect:
The current implementation does NOT properly implement token exchange.
MCP session tokens should be EXCHANGED for delegated Nextcloud tokens
during tool calls, not stored/reused. Critical corrections needed:
1. Session tokens: Flow 1 token → exchange → ephemeral Nextcloud token
- Generated on-demand per tool call
- Short-lived, not stored
- Scopes limited to tool requirements
2. Background tokens: Flow 2 refresh token → background Nextcloud token
- Only for offline/background jobs
- Potentially different scopes than session tokens
- Must NOT be used for MCP session tool calls
The token exchange mechanism needs to be implemented to properly
separate session-time delegation from background job authorization.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
- Token Broker Service manages Nextcloud access tokens with audience validation
- Implements short-lived token caching (5-minute TTL) with early refresh
- Enhanced token storage schema with ADR-004 fields (flow_type, audience, provisioning)
- MCP provisioning tools for explicit Flow 2 resource authorization
- Comprehensive unit tests for Token Broker Service (14 tests, all passing)
- Environment configuration for Progressive Consent mode
This implements the foundation for the dual OAuth flow architecture where:
- Flow 1: MCP clients authenticate to MCP server (aud: "mcp-server")
- Flow 2: MCP server gets delegated Nextcloud access (aud: "nextcloud")
Users must explicitly call provision_nextcloud_access tool to grant resource access,
implementing the "stateless by default" principle from ADR-004.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
- Import recipes from URLs using schema.org metadata
- Full CRUD operations for recipes
- Search, categorize, and organize recipes
- Manage keywords/tags and categories
- Configure app settings and trigger reindexing