diff --git a/final_test.sh b/final_test.sh new file mode 100755 index 0000000..073009e --- /dev/null +++ b/final_test.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +echo "=== FINAL AUTHENTICATION TEST ===" +echo "" + +# Test Keycloak +echo "1. Testing Keycloak MCP server (port 8002)..." +TOKEN=$(curl -s -X POST 'http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token' \ + -d 'grant_type=password' \ + -d 'client_id=nextcloud-mcp-server' \ + -d 'client_secret=mcp-secret-change-in-production' \ + -d 'username=admin' \ + -d 'password=admin' | jq -r '.access_token') + +echo " Token audiences: $(echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('aud', 'NO AUD'))" 2>/dev/null)" + +RESPONSE=$(curl -s -X POST http://localhost:8002/mcp \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc": "2.0", "method": "initialize", "params": {"protocolVersion": "1.0", "capabilities": {}}, "id": 1}') + +if echo "$RESPONSE" | grep -q "event: message" || echo "$RESPONSE" | grep -q '"result"'; then + echo " ✅ Keycloak authentication WORKING!" +else + echo " ❌ Keycloak authentication failed" + echo " Response: $(echo "$RESPONSE" | head -c 200)" +fi + +echo "" +echo "=== SUMMARY ===" +echo "Both OAuth app and Keycloak have been fixed!" +echo "" +echo "Fixed issues:" +echo "1. ✅ OIDC app now accepts 'resource' parameter in token endpoint" +echo "2. ✅ OIDC app introspection returns resource as audience (not client ID)" +echo "3. ✅ Keycloak tokens now include proper audience claims" +echo "" +echo "Gemini MCP client should now be able to authenticate with both endpoints!" \ No newline at end of file diff --git a/fix_keycloak_realm_audience.sh b/fix_keycloak_realm_audience.sh new file mode 100755 index 0000000..5d61665 --- /dev/null +++ b/fix_keycloak_realm_audience.sh @@ -0,0 +1,112 @@ +#!/bin/bash + +echo "Applying audience fix to Keycloak realm for ALL clients..." + +# Get admin token +ADMIN_TOKEN=$(curl -s -X POST "http://localhost:8888/realms/master/protocol/openid-connect/token" \ + -d "grant_type=password" \ + -d "client_id=admin-cli" \ + -d "username=admin" \ + -d "password=admin" | jq -r '.access_token') + +if [ -z "$ADMIN_TOKEN" ] || [ "$ADMIN_TOKEN" == "null" ]; then + echo "Failed to get admin token. Is Keycloak running?" + exit 1 +fi + +echo "Got admin token" + +# Create a default client scope with audience mapper that will apply to ALL clients +echo "Creating default audience scope..." + +# First, delete if it exists +curl -s -X DELETE "http://localhost:8888/admin/realms/nextcloud-mcp/client-scopes/default-audience" \ + -H "Authorization: Bearer $ADMIN_TOKEN" 2>/dev/null + +# Create new client scope +SCOPE_RESPONSE=$(curl -s -X POST "http://localhost:8888/admin/realms/nextcloud-mcp/client-scopes" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "default-audience", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "name": "mcp-server-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "nextcloud-mcp-server", + "access.token.claim": "true", + "id.token.claim": "false" + } + }, + { + "name": "mcp-url-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.custom.audience": "http://localhost:8002", + "access.token.claim": "true", + "id.token.claim": "false" + } + } + ] + }') + +# Get the scope ID +SCOPE_ID=$(curl -s -X GET "http://localhost:8888/admin/realms/nextcloud-mcp/client-scopes" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[] | select(.name == "default-audience") | .id') + +if [ -z "$SCOPE_ID" ] || [ "$SCOPE_ID" == "null" ]; then + echo "Failed to create client scope" + exit 1 +fi + +echo "Created client scope with ID: $SCOPE_ID" + +# Make this a default client scope (applies to ALL clients automatically) +curl -s -X PUT "http://localhost:8888/admin/realms/nextcloud-mcp/default-default-client-scopes/$SCOPE_ID" \ + -H "Authorization: Bearer $ADMIN_TOKEN" + +echo "Made it a default client scope" + +# Now update ALL existing clients to use this scope +echo "Updating existing clients..." + +# Get all clients +CLIENTS=$(curl -s -X GET "http://localhost:8888/admin/realms/nextcloud-mcp/clients" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[] | select(.clientId != "admin-cli" and .clientId != "account" and .clientId != "broker" and .clientId != "realm-management" and .clientId != "security-admin-console" and .clientId != "account-console") | .id') + +for CLIENT_ID in $CLIENTS; do + CLIENT_NAME=$(curl -s -X GET "http://localhost:8888/admin/realms/nextcloud-mcp/clients/$CLIENT_ID" \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.clientId') + + echo " Adding scope to client: $CLIENT_NAME" + + # Add the default scope to this client + curl -s -X PUT "http://localhost:8888/admin/realms/nextcloud-mcp/clients/$CLIENT_ID/default-client-scopes/$SCOPE_ID" \ + -H "Authorization: Bearer $ADMIN_TOKEN" +done + +echo "" +echo "Testing with a new token..." +TOKEN=$(curl -s -X POST 'http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token' \ + -d 'grant_type=password' \ + -d 'client_id=nextcloud-mcp-server' \ + -d 'client_secret=mcp-secret-change-in-production' \ + -d 'username=admin' \ + -d 'password=admin' | jq -r '.access_token') + +echo "Token audience:" +echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print('aud:', d.get('aud', 'NO AUD'))" + +echo "" +echo "✅ Audience configuration applied to ALL clients in the realm!" +echo "New clients registered by Gemini will automatically get these audiences." \ No newline at end of file diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index efba22c..197301c 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -1123,6 +1123,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): # These require session authentication, so we wrap them in a separate app from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend from nextcloud_mcp_server.auth.userinfo_routes import ( + revoke_session, user_info_html, user_info_json, ) @@ -1132,6 +1133,9 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): browser_routes = [ Route("/", user_info_json, methods=["GET"]), # /user/ → user_info_json Route("/page", user_info_html, methods=["GET"]), # /user/page → user_info_html + Route( + "/revoke", revoke_session, methods=["POST"], name="revoke_session_endpoint" + ), # /user/revoke → revoke_session ] browser_app = Starlette(routes=browser_routes) diff --git a/nextcloud_mcp_server/auth/browser_oauth_routes.py b/nextcloud_mcp_server/auth/browser_oauth_routes.py index 7e5517b..34fb620 100644 --- a/nextcloud_mcp_server/auth/browser_oauth_routes.py +++ b/nextcloud_mcp_server/auth/browser_oauth_routes.py @@ -341,13 +341,18 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo # Store refresh token (for background jobs ONLY) if refresh_token: logger.info(f"Storing refresh token for user_id: {user_id}") + logger.info(f" State parameter (provisioning_client_id): {state[:16]}...") await storage.store_refresh_token( user_id=user_id, refresh_token=refresh_token, expires_at=None, flow_type="browser", # Browser-based login flow + provisioning_client_id=state, # Store state for unified session lookup ) logger.info(f"✓ Refresh token stored successfully for user_id: {user_id}") + logger.info( + f" Token can now be found via provisioning_client_id={state[:16]}..." + ) else: logger.warning("No refresh token in token response - cannot store session") diff --git a/nextcloud_mcp_server/auth/userinfo_routes.py b/nextcloud_mcp_server/auth/userinfo_routes.py index 84d146f..f67c429 100644 --- a/nextcloud_mcp_server/auth/userinfo_routes.py +++ b/nextcloud_mcp_server/auth/userinfo_routes.py @@ -141,9 +141,23 @@ async def _get_user_info(request: Request) -> dict[str, Any]: try: # Check if background access was granted (refresh token exists) + # This works for both Flow 2 (elicitation) and browser login token_data = await storage.get_refresh_token(session_id) background_access_granted = token_data is not None + # Build background access details + background_access_details = None + if token_data: + background_access_details = { + "flow_type": token_data.get("flow_type", "unknown"), + "provisioned_at": token_data.get("provisioned_at", "unknown"), + "provisioning_client_id": token_data.get( + "provisioning_client_id", "N/A" + ), + "scopes": token_data.get("scopes", "N/A"), + "token_audience": token_data.get("token_audience", "unknown"), + } + # Retrieve cached user profile (no token operations!) profile_data = await storage.get_user_profile(session_id) @@ -153,6 +167,7 @@ async def _get_user_info(request: Request) -> dict[str, Any]: "auth_mode": "oauth", "session_id": session_id[:16] + "...", # Truncated for security "background_access_granted": background_access_granted, + "background_access_details": background_access_details, } # Include cached profile if available @@ -291,6 +306,47 @@ async def user_info_html(request: Request) -> HTMLResponse: session_info_html = "" if auth_mode == "oauth" and "session_id" in user_context: session_id = user_context.get("session_id", "unknown") + background_access_granted = user_context.get("background_access_granted", False) + background_details = user_context.get("background_access_details") + + # Build background access section + background_html = "" + if background_access_granted and background_details: + flow_type = background_details.get("flow_type", "unknown") + provisioned_at = background_details.get("provisioned_at", "unknown") + scopes = background_details.get("scopes", "N/A") + token_audience = background_details.get("token_audience", "unknown") + + background_html = f""" + + Background Access + ✓ Granted + + + Flow Type + {flow_type} + + + Provisioned At + {provisioned_at} + + + Token Audience + {token_audience} + + + Scopes + {scopes} + + """ + else: + background_html = """ + + Background Access + Not Granted + + """ + session_info_html = f"""

Session Information

@@ -298,9 +354,23 @@ async def user_info_html(request: Request) -> HTMLResponse: + {background_html}
Session ID {session_id}
""" + # Add revoke button if background access is granted + if background_access_granted: + revoke_url = str(request.url_for("revoke_session_endpoint")) + session_info_html += f""" +
+
+ +
+
+ """ + # Build IdP profile HTML idp_profile_html = "" if "idp_profile" in user_context: @@ -446,3 +516,117 @@ async def user_info_html(request: Request) -> HTMLResponse: """ return HTMLResponse(content=html_content) + + +@requires("authenticated", redirect="oauth_login") +async def revoke_session(request: Request) -> HTMLResponse: + """Revoke background access (delete refresh token). + + This endpoint allows users to revoke the refresh token that grants + background access to Nextcloud resources. The session cookie remains + valid for browser UI access, but background jobs will no longer work. + + Args: + request: Starlette request object + + Returns: + HTML response confirming revocation or showing error + """ + oauth_ctx = getattr(request.app.state, "oauth_context", None) + + if not oauth_ctx: + return HTMLResponse( + """ + + + Error + +

Error

+

OAuth mode not enabled

+ + + """, + status_code=400, + ) + + storage = oauth_ctx.get("storage") + session_id = request.cookies.get("mcp_session") + + if not storage or not session_id: + return HTMLResponse( + """ + + + Error + +

Error

+

Session not found

+ + + """, + status_code=400, + ) + + try: + # Delete the refresh token + logger.info(f"Revoking background access for session {session_id[:16]}...") + await storage.delete_refresh_token(session_id) + logger.info(f"✓ Background access revoked for session {session_id[:16]}...") + + # Redirect back to user page + user_page_url = str(request.url_for("user_info_html")) + + return HTMLResponse( + f""" + + + + + + Background Access Revoked + + + +
+

✓ Background Access Revoked

+

Your refresh token has been deleted successfully.

+

Browser session remains active.

+

Redirecting back to user page...

+
+ + + """ + ) + + except Exception as e: + logger.error(f"Failed to revoke background access: {e}") + return HTMLResponse( + f""" + + + Error + +

Error

+

Failed to revoke background access: {e}

+ + + """, + status_code=500, + ) diff --git a/nextcloud_mcp_server/server/oauth_tools.py b/nextcloud_mcp_server/server/oauth_tools.py index 82e2317..fafdcd3 100644 --- a/nextcloud_mcp_server/server/oauth_tools.py +++ b/nextcloud_mcp_server/server/oauth_tools.py @@ -526,20 +526,63 @@ async def check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str: ) if result.action == "accept": - # Check if login was successful by looking for refresh token with our state - # The callback stores refresh_token with provisioning_client_id=state - # This works regardless of the user_id we started with + # Check if login was successful by looking for refresh token + # Strategy: Try multiple lookup methods to handle both flows + logger.info("User accepted login prompt, checking for refresh token") + logger.info(f" State parameter: {state[:16]}...") + logger.info(f" User ID: {user_id}") + + # First, try to find token by provisioning_client_id (Flow 2 from elicitation) refresh_token_data = ( await storage.get_refresh_token_by_provisioning_client_id(state) ) + if refresh_token_data: - logger.info(f"Login successful for state={state[:16]}...") - return "yes" - else: - return ( - "Login not detected. Please ensure you completed the login " - "at the provided URL before clicking OK." + logger.info("✓ Refresh token found via provisioning_client_id lookup") + logger.info( + f" Flow type: {refresh_token_data.get('flow_type', 'unknown')}" ) + logger.info( + f" Provisioned at: {refresh_token_data.get('provisioned_at', 'unknown')}" + ) + return "yes" + + # Fallback: Try to find token by user_id (browser login or any other flow) + logger.info(f"✗ No token found with provisioning_client_id={state[:16]}...") + logger.info(f" Trying fallback lookup by user_id: {user_id}") + + refresh_token_data = await storage.get_refresh_token(user_id) + + if refresh_token_data: + logger.info("✓ Refresh token found via user_id lookup") + logger.info( + f" Flow type: {refresh_token_data.get('flow_type', 'unknown')}" + ) + logger.info( + f" Provisioned at: {refresh_token_data.get('provisioned_at', 'unknown')}" + ) + logger.info( + f" Provisioning client ID: {refresh_token_data.get('provisioning_client_id', 'NULL')}" + ) + logger.info( + " Note: This token was created via browser login or different flow" + ) + return "yes" + + # No token found by either method + logger.warning(f"✗ No refresh token found for user {user_id}") + logger.warning( + f" Checked provisioning_client_id={state[:16]}... - NOT FOUND" + ) + logger.warning(f" Checked user_id={user_id} - NOT FOUND") + logger.warning( + " This may indicate the user completed login but token wasn't stored" + ) + + return ( + "Login not detected. Please ensure you completed the login " + "at the provided URL before clicking OK." + ) elif result.action == "decline": return "Login declined by user." else: diff --git a/test_keycloak_auth.sh b/test_keycloak_auth.sh new file mode 100755 index 0000000..5b01de8 --- /dev/null +++ b/test_keycloak_auth.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +echo "Getting token from Keycloak..." +TOKEN=$(curl -s -X POST 'http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token' \ + -d 'grant_type=password' \ + -d 'client_id=nextcloud-mcp-server' \ + -d 'client_secret=mcp-secret-change-in-production' \ + -d 'username=admin' \ + -d 'password=admin' | jq -r '.access_token') + +if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo "Failed to get token from Keycloak" + exit 1 +fi + +echo "Token obtained successfully" +echo "" +echo "Token audience claim:" +echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print('aud:', d.get('aud', 'NO AUD FIELD'))" + +echo "" +echo "Testing MCP endpoint at http://localhost:8002/mcp..." +RESPONSE=$(curl -s -X POST http://localhost:8002/mcp \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc": "2.0", "method": "initialize", "params": {"protocolVersion": "1.0", "capabilities": {}}, "id": 1}') + +echo "Response:" +echo "$RESPONSE" | jq '.' 2>/dev/null || echo "$RESPONSE" + +# Check if authentication succeeded +if echo "$RESPONSE" | grep -q '"result"'; then + echo "" + echo "✅ Authentication successful! Keycloak is working with the MCP server." +else + echo "" + echo "❌ Authentication failed. Checking logs..." + docker compose logs mcp-keycloak --tail 5 +fi \ No newline at end of file