From 17979accb67ae42296e9daf71e805c2e78a30ad1 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 13 Oct 2025 18:07:50 +0200 Subject: [PATCH] test: Add patch for user_oidc app and update docs --- ...-authentication-causing-session-logo.patch | 69 +++++++++++++ .../post-installation/install-oidc-app.sh | 6 +- docs/oauth2-bearer-token-session-issue.md | 97 +++++++++++++++++++ docs/user_oidc-pr-description.md | 96 ++++++++++++++++++ tests/conftest.py | 39 +++++++- 5 files changed, 300 insertions(+), 7 deletions(-) create mode 100644 app-hooks/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch create mode 100644 docs/oauth2-bearer-token-session-issue.md create mode 100644 docs/user_oidc-pr-description.md diff --git a/app-hooks/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch b/app-hooks/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch new file mode 100644 index 0000000..c578441 --- /dev/null +++ b/app-hooks/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch @@ -0,0 +1,69 @@ +From deab2dac3d73d25f20a95c18103f327ab48f837a Mon Sep 17 00:00:00 2001 +From: Chris Coutinho +Date: Sun, 12 Oct 2025 21:09:29 +0200 +Subject: [PATCH 1/1] Fix Bearer token authentication causing session logout + +When using Bearer token authentication with OIDC, API requests to +endpoints with @CORS annotations (like Notes API) were failing with +401 Unauthorized errors. This occurred because: + +1. Bearer token validation successfully authenticated the user +2. A session was created for the authenticated user +3. Nextcloud's CORSMiddleware detected the logged-in session but no + CSRF token, causing it to call session->logout() +4. The logout invalidated the session, breaking the API request + +This fix sets the 'app_api' session flag during Bearer token +authentication, which instructs CORSMiddleware to skip the CSRF check +and logout logic. This is the same mechanism used by Nextcloud's +AppAPI framework for external application authentication. + +The flag is set at all successful Bearer token authentication points: +- Line 243: After OIDC Identity Provider validation +- Line 310: After auto-provisioning with bearer provisioning +- Line 315: After existing user authentication +- Line 337: After LDAP user sync + +Fixes: Bearer token authentication for all Nextcloud APIs +Tested-with: nextcloud-mcp-server integration tests +Signed-off-by: Chris Coutinho +--- + lib/User/Backend.php | 4 ++++ + 1 file changed, 4 insertions(+) + +diff --git a/lib/User/Backend.php b/lib/User/Backend.php +index 23cfb18..65665cc 100644 +--- a/lib/User/Backend.php ++++ b/lib/User/Backend.php +@@ -240,6 +240,7 @@ class Backend extends ABackend implements IPasswordConfirmationBackend, IGetDisp + $this->eventDispatcher->dispatchTyped($validationEvent); + $oidcProviderUserId = $validationEvent->getUserId(); + if ($oidcProviderUserId !== null) { ++ $this->session->set('app_api', true); + return $oidcProviderUserId; + } else { + $this->logger->debug('[NextcloudOidcProviderValidator] The bearer token validation has failed'); +@@ -306,10 +307,12 @@ class Backend extends ABackend implements IPasswordConfirmationBackend, IGetDisp + } + + $this->session->set('last-password-confirm', strtotime('+4 year', time())); ++ $this->session->set('app_api', true); + return $userId; + } elseif ($this->userExists($tokenUserId)) { + $this->checkFirstLogin($tokenUserId); + $this->session->set('last-password-confirm', strtotime('+4 year', time())); ++ $this->session->set('app_api', true); + return $tokenUserId; + } else { + // check if the user exists locally +@@ -331,6 +334,7 @@ class Backend extends ABackend implements IPasswordConfirmationBackend, IGetDisp + } + $this->checkFirstLogin($tokenUserId); + $this->session->set('last-password-confirm', strtotime('+4 year', time())); ++ $this->session->set('app_api', true); + return $tokenUserId; + } + } +-- +2.51.0 + diff --git a/app-hooks/post-installation/install-oidc-app.sh b/app-hooks/post-installation/install-oidc-app.sh index 3c18998..656f72f 100755 --- a/app-hooks/post-installation/install-oidc-app.sh +++ b/app-hooks/post-installation/install-oidc-app.sh @@ -5,13 +5,15 @@ set -euox pipefail echo "Installing and configuring OIDC apps for testing..." # Enable the OIDC Identity Provider app -php /var/www/html/occ app:install oidc || true +#php /var/www/html/occ app:install oidc || true php /var/www/html/occ app:enable oidc # Enable the user_oidc app (OIDC client for bearer token validation) -php /var/www/html/occ app:install user_oidc || true +#php /var/www/html/occ app:install user_oidc || true php /var/www/html/occ app:enable user_oidc +patch -u /var/www/html/custom_apps/user_oidc/lib/User/Backend.php -i /docker-entrypoint-hooks.d/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch + # Configure OIDC Identity Provider with dynamic client registration enabled php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true' diff --git a/docs/oauth2-bearer-token-session-issue.md b/docs/oauth2-bearer-token-session-issue.md new file mode 100644 index 0000000..797c101 --- /dev/null +++ b/docs/oauth2-bearer-token-session-issue.md @@ -0,0 +1,97 @@ +# Root Cause Analysis: OAuth2 Bearer Token Session Invalidation + +## Problem +Bearer token authentication fails for app-specific APIs (like Notes) with 401 Unauthorized, even though it works for OCS APIs (capabilities). + +## Root Cause +The CORSMiddleware in Nextcloud server is logging out the session created by Bearer token authentication: + +``` +/home/chris/Software/server/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php:84 +$this->session->logout(); +``` + +### Why Session is Logged Out +1. Notes API has @CORS annotation +2. Bearer auth via user_oidc creates a logged-in session +3. Request has NO CSRF token +4. Request has NO AppAPI auth flag +5. Request has NO PHP_AUTH_USER/PHP_AUTH_PW (basic auth) +6. Therefore CORSMiddleware calls logout() + +### Log Evidence +``` +{"message":"[TokenInvalidatedListener] Could not find the OIDC session related with an invalidated token"} +``` + +Token validated successfully, then immediately invalidated by session logout. + +## Token Type Investigation (Opaque vs JWT) +- **Finding**: Token type (opaque vs JWT) does NOT affect the issue +- **Reason**: Session invalidation happens AFTER successful token validation +- Both opaque and JWT tokens validate correctly via TokenValidationRequestEvent +- The logout happens in CORSMiddleware, not in token validation + +## ✅ SOLUTION (Tested & Working) + +### Option A: Set AppAPI Flag for Bearer Auth ✅ +**Status**: Successfully tested and verified working + +Modified user_oidc `Backend.php` `getCurrentUserId()` method to set the `app_api` session flag before returning the user ID: + +```php +$this->session->set('app_api', true); +``` + +This bypasses CORS middleware's logout logic at line 81-82 by setting the same flag used by Nextcloud's AppAPI framework. + +### Implementation +The flag is added before all successful Bearer token authentication return statements in `/var/www/html/custom_apps/user_oidc/lib/User/Backend.php`: + +- Line ~243: After OIDC provider validation +- Line ~310: After auto-provisioning with bearer provisioning +- Line ~315: After existing user authentication +- Line ~337: After LDAP user sync + +### Test Results +All OAuth Bearer token operations now work correctly: + +✅ **Capabilities endpoint** (OCS API) - 200 OK +✅ **Notes API listing** - 200 OK +✅ **Notes API create** - 200 OK (created note 112) +✅ **Notes API delete** - 200 OK (deleted note 112) + +No session invalidation occurs, and all API operations complete successfully. + +### Patch File +See `patches/user_oidc-bearer-auth-app-api-flag.patch` for the exact changes. + +## Alternative Solutions (Not Tested) + +### Option B: Avoid Creating Full Session for Bearer Auth +Bearer token auth should not create a full session that triggers CORS middleware checks. This would require deeper architectural changes. + +### Option C: Add CSRF Exemption +Modify CORSMiddleware to exempt Bearer token authenticated requests from CSRF check. This would require changes to Nextcloud core. + +### Option D: Use Basic Auth Headers +Set PHP_AUTH_USER/PHP_AUTH_PW server variables during Bearer auth so CORSMiddleware can re-authenticate. This could have security implications. + +## Recommendations + +### Short-term (Current Implementation) +The `app_api` flag solution works correctly and follows Nextcloud's existing pattern for API authentication. This is the recommended approach for immediate use. + +### Long-term (Upstream Contribution) +Consider submitting this fix to the upstream user_oidc project as it enables proper Bearer token authentication for all Nextcloud APIs, not just OCS endpoints. + +## Files Involved +- `/home/chris/Software/user_oidc/lib/User/Backend.php` (getCurrentUserId) - **MODIFIED** +- `/home/chris/Software/server/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php` (logout logic) +- `/home/chris/Software/user_oidc/lib/Listener/TokenInvalidatedListener.php` (cleanup handler) + +## Testing +Run the OAuth interactive test to verify: +```bash +uv run pytest tests/integration/test_oauth_interactive.py -v +``` diff --git a/docs/user_oidc-pr-description.md b/docs/user_oidc-pr-description.md new file mode 100644 index 0000000..d8829b2 --- /dev/null +++ b/docs/user_oidc-pr-description.md @@ -0,0 +1,96 @@ +# Fix Bearer Token Authentication Causing Session Logout + +## Problem + +Bearer token authentication with OIDC fails for app-specific APIs (like Notes, Calendar, etc.) with `401 Unauthorized` errors, even though the same Bearer token works fine for OCS APIs (like `/ocs/v2.php/cloud/capabilities`). + +### Root Cause + +When using Bearer token authentication: + +1. ✅ Bearer token validation successfully authenticates the user +2. ✅ A session is created for the authenticated user +3. ❌ **Nextcloud's `CORSMiddleware` detects the logged-in session but no CSRF token** +4. ❌ **`CORSMiddleware` calls `$this->session->logout()` to prevent CSRF attacks** +5. ❌ The logout invalidates the session, breaking the API request with 401 Unauthorized + +This occurs because app-specific APIs (Notes, Calendar, etc.) use the `@CORS` annotation, which triggers the `CORSMiddleware` security checks. The OCS APIs don't have this annotation, which is why they work correctly. + +### Error Logs + +``` +[TokenInvalidatedListener] Could not find the OIDC session related with an invalidated token +Session token invalidated before logout +Logging out +``` + +## Solution + +Set the `app_api` session flag during Bearer token authentication. This instructs `CORSMiddleware` to skip the CSRF check and logout logic, as the authentication is API-based rather than session-based. + +This is the same mechanism used by Nextcloud's [AppAPI framework](https://github.com/cloud-py-api/app_api) for external application authentication. + +### Changes + +The fix adds `$this->session->set('app_api', true);` before all successful Bearer token authentication return statements in `lib/User/Backend.php`: + +- **Line 243**: After OIDC Identity Provider validation +- **Line 310**: After auto-provisioning with bearer provisioning +- **Line 315**: After existing user authentication +- **Line 337**: After LDAP user sync + +## Testing + +Tested with the [nextcloud-mcp-server](https://github.com/cccs-nik/nextcloud-mcp-server) project's integration tests: + +### Before Fix +``` +✅ Capabilities endpoint (OCS API) - 200 OK +❌ Notes API listing - 401 Unauthorized +❌ Notes API create - 401 Unauthorized +``` + +### After Fix +``` +✅ Capabilities endpoint (OCS API) - 200 OK +✅ Notes API listing - 200 OK +✅ Notes API create - 200 OK +✅ Notes API delete - 200 OK +``` + +All OAuth Bearer token operations now work correctly across all Nextcloud APIs without session invalidation. + +## Configuration + +This fix works with the standard Bearer token validation configuration: + +```php +// config.php +'user_oidc' => [ + 'oidc_provider_bearer_validation' => true, +], +``` + +And in the OIDC Identity Provider app: +```bash +php occ config:app:set oidc dynamic_client_registration --value='true' +``` + +## Impact + +This fix enables proper Bearer token authentication for: +- All Nextcloud app APIs (Notes, Calendar, Contacts, etc.) +- External applications using OAuth 2.0 / OpenID Connect +- MCP servers and other API integrations +- Any application using the `Authorization: Bearer` header + +## Related Files + +- `lib/User/Backend.php` - Modified to set `app_api` flag +- `/server/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php` - Contains the CSRF/logout logic that this bypasses + +## References + +- [Nextcloud CORS Middleware](https://github.com/nextcloud/server/blob/master/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php) +- [Nextcloud AppAPI](https://github.com/cloud-py-api/app_api) +- [OpenID Connect Bearer Token Usage](https://openid.net/specs/openid-connect-core-1_0.html#TokenUsage) diff --git a/tests/conftest.py b/tests/conftest.py index 745c033..f5fcdb7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -602,13 +602,17 @@ async def interactive_oauth_token() -> str: from urllib.parse import urlparse, parse_qs import time - auth_code = None + # Use a mutable container to share state across threads + auth_state = {"code": None} httpd = None server_thread = None class OAuthCallbackHandler(BaseHTTPRequestHandler): + def log_message(self, format, *args): + # Suppress default HTTP logging + pass + def do_GET(self): - nonlocal auth_code if self.path.startswith("/shutdown"): self.send_response(200) self.send_header("Content-type", "text/html") @@ -621,7 +625,11 @@ async def interactive_oauth_token() -> str: parsed_path = urlparse(self.path) query = parse_qs(parsed_path.query) - auth_code = query.get("code", [None])[0] + code = query.get("code", [None])[0] + auth_state["code"] = code + logger.info( + f"OAuth callback received. Code: {code[:20] if code else 'None'}..." + ) self.send_response(200) self.send_header("Content-type", "text/html") self.end_headers() @@ -652,12 +660,33 @@ async def interactive_oauth_token() -> str: force_register=True, ) + # First, open Nextcloud login page to establish session + login_url = f"{nextcloud_host}/login" + logger.info(f"Please log in to Nextcloud at: {login_url}") + logger.info( + "After logging in, the OAuth authorization will proceed automatically" + ) + + # Construct authorization URL auth_url = f"{authorization_endpoint}?response_type=code&client_id={client_info.client_id}&redirect_uri=http://localhost:8081&scope=openid%20profile%20email" + + # Open login page first, then auth URL + # webbrowser.open(login_url) + # time.sleep(2) # Give browser time to load login page webbrowser.open(auth_url) - while not auth_code: - logger.info("Sleeping until auth_code available") + + # Wait for auth code with timeout + timeout = 120 # 2 minutes + start_time = time.time() + while not auth_state["code"]: + if time.time() - start_time > timeout: + raise TimeoutError("OAuth authorization timed out after 2 minutes") + logger.info("Waiting for OAuth authorization...") time.sleep(1) + auth_code = auth_state["code"] + logger.info("Received authorization code, exchanging for token...") + token_response = await http_client.post( token_endpoint, data={