test: Add patch for user_oidc app and update docs

This commit is contained in:
Chris Coutinho
2025-10-13 18:07:50 +02:00
parent 7d8ba39434
commit 17979accb6
5 changed files with 300 additions and 7 deletions
@@ -0,0 +1,69 @@
From deab2dac3d73d25f20a95c18103f327ab48f837a Mon Sep 17 00:00:00 2001
From: Chris Coutinho <chris@coutinho.io>
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 <chris@coutinho.io>
---
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
@@ -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'
+97
View File
@@ -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
```
+96
View File
@@ -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)
+34 -5
View File
@@ -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={