test: Add patch for user_oidc app and update docs
This commit is contained in:
+69
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
@@ -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={
|
||||
|
||||
Reference in New Issue
Block a user