Compare commits

...

14 Commits

Author SHA1 Message Date
Chris Coutinho 48744e8a6c ci: Publish to PyPI 2025-10-20 23:14:12 +02:00
Chris Coutinho 63b898c0e3 chore: Update logs 2025-10-20 22:57:18 +02:00
Chris Coutinho e8f1340133 fix(caldav): Fix caldav search() due to missing todos 2025-10-20 22:18:46 +02:00
Chris Coutinho fde68dac55 ci: Enable publish to test pypi 2025-10-20 20:27:01 +02:00
Chris Coutinho 460e2e190c ci: set workflow to be on workflow_dispatch 2025-10-20 20:22:07 +02:00
Chris Coutinho 989b6de3c0 build: Switch to uv build backend 2025-10-20 20:10:57 +02:00
Chris Coutinho aa0b6dc5dd docs: Update docs 2025-10-20 19:10:23 +02:00
Chris Coutinho 7ae78d3a39 Merge pull request #225 from cbcoutinho/feature/oidc-bump
Remove patch for OIDC app
2025-10-20 16:02:37 +02:00
Chris Coutinho 54326f9c64 Remove patch for OIDC app 2025-10-20 15:50:11 +02:00
Chris Coutinho 6ba87e7e05 chore: update caldav ref 2025-10-20 11:52:29 +02:00
github-actions[bot] 45bbf97033 bump: version 0.16.0 → 0.17.0 2025-10-19 22:55:23 +00:00
Chris Coutinho 14a0f166fe Merge pull request #223 from cbcoutinho/feature/caldav
Migrate to caldav and add support for VTODOs
2025-10-20 00:54:51 +02:00
Chris Coutinho 61bb8cc048 Merge pull request #224 from cbcoutinho/renovate/astral-sh-setup-uv-7.x
chore(deps): update astral-sh/setup-uv action to v7.1.1
2025-10-20 00:15:05 +02:00
renovate-bot-cbcoutinho[bot] ad9b9f25a1 chore(deps): update astral-sh/setup-uv action to v7.1.1 2025-10-19 22:05:34 +00:00
13 changed files with 173 additions and 450 deletions
+29
View File
@@ -0,0 +1,29 @@
name: Release
on:
push:
tags:
- v*
jobs:
pypi:
name: Publish to PyPI
runs-on: ubuntu-latest
# Environment and permissions trusted publishing.
environment:
# Create this environment in the GitHub repository under Settings -> Environments
name: pypi
permissions:
id-token: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install Python 3.11
run: uv python install 3.11
- name: Build
run: uv build
- name: Publish
run: uv publish
+2 -2
View File
@@ -11,7 +11,7 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Install the latest version of uv
uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
- name: Check format
run: |
uv run --frozen ruff format --diff
@@ -33,7 +33,7 @@ jobs:
up-flags: "--build"
- name: Install the latest version of uv
uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
- name: Install Playwright dependencies
run: |
+15
View File
@@ -1,3 +1,18 @@
## v0.17.0 (2025-10-19)
### Feat
- **caldav**: Add support for tasks
### Fix
- **caldav**: Check that calendar exists after creation to avoid race condition
- **caldav**: Properly parse datetimes as vDDDTypes
### Refactor
- Migrate from internal CalendarClient to caldav library
## v0.16.0 (2025-10-19)
### Feat
+32 -20
View File
@@ -7,22 +7,33 @@
The Nextcloud MCP (Model Context Protocol) server allows Large Language Models like Claude, GPT, and Gemini to interact with your Nextcloud data through a secure API. Create notes, manage calendars, organize contacts, work with files, and more - all through natural language.
> [!NOTE]
> **Nextcloud has two ways to enable AI access:** Nextcloud provides [Context Agent](https://github.com/nextcloud/context_agent), an AI agent backend that powers the [Assistant](https://github.com/nextcloud/assistant) app and allows AI to interact with Nextcloud apps like Calendar, Talk, and Contacts. Context Agent runs as an ExApp inside Nextcloud and also exposes an MCP server endpoint for external LLMs. This project (Nextcloud MCP Server) is a **dedicated standalone MCP server** designed specifically for external MCP clients like Claude Code and IDEs, with deep CRUD operations and OAuth support. See our [detailed comparison](docs/comparison-context-agent.md) to understand which approach fits your use case.
> **Nextcloud has two ways to enable AI access:** Nextcloud provides [Context Agent](https://github.com/nextcloud/context_agent), an AI agent backend that powers the [Assistant](https://github.com/nextcloud/assistant) app and allows AI to interact with Nextcloud apps like Calendar, Talk, and Contacts. Context Agent runs as an ExApp inside Nextcloud and also exposes an MCP server endpoint for external LLMs. This project (Nextcloud MCP Server) is a **dedicated standalone MCP server** designed specifically for external MCP clients like Claude Code and IDEs, with deep CRUD operations and OAuth support.
## Features
### High-level Comparison: Nextcloud MCP Server vs. Nextcloud AI Stack
### Supported Nextcloud Apps
| Aspect | **Nextcloud MCP Server**<br/>(This Project) | **Nextcloud AI Stack**<br/>(Assistant + Context Agent) |
|--------|---------------------------------------------|--------------------------------------------------------|
| **Purpose** | External MCP client access to Nextcloud | AI assistance within Nextcloud UI |
| **Deployment** | Standalone (Docker, VM, K8s) | Inside Nextcloud (ExApp via AppAPI) |
| **Primary Users** | Claude Code, IDEs, external developers | Nextcloud end users via Assistant app |
| **Authentication** | OAuth2/OIDC or Basic Auth | Session-based (integrated) |
| **Notes Support** | ✅ Full CRUD + search (7 tools) | ❌ Not implemented |
| **Calendar** | ✅ Full CalDAV + tasks (20+ tools) | ✅ Events, free/busy, tasks (4 tools) |
| **Contacts** | ✅ Full CardDAV (8 tools) | ✅ Find person, current user (2 tools) |
| **Files (WebDAV)** | ✅ Full filesystem access (12 tools) | ✅ Read, folder tree, sharing (3 tools) |
| **Deck** | ✅ Full project management (15 tools) | ✅ Basic board/card ops (2 tools) |
| **Tables** | ✅ Row operations (5 tools) | ❌ Not implemented |
| **Cookbook** | ✅ Full recipe management (13 tools) | ❌ Not implemented |
| **Talk** | ❌ Not implemented | ✅ Messages, conversations (4 tools) |
| **Mail** | ❌ Not implemented | ✅ Send email (2 tools) |
| **AI Features** | ❌ Not implemented | ✅ Image gen, transcription, doc gen (4 tools) |
| **Web/Maps** | ❌ Not implemented | ✅ Search, weather, transit (5 tools) |
| **MCP Resources** | ✅ Structured data URIs | ❌ Not supported |
| **External MCP** | ❌ Pure server | ✅ Consumes external MCP servers |
| **Safety Model** | Client-controlled | Built-in safe/dangerous distinction |
| **Best For** | • Deep CRUD operations<br/>• External integrations<br/>• OAuth security<br/>• IDE/editor integration | • AI-driven actions in Nextcloud UI<br/>• Multi-service orchestration<br/>• User task automation<br/>• MCP aggregation hub |
| App | Support | Features |
|-----|---------|----------|
| **Notes** | ✅ Full | Create, read, update, delete, search notes. Handle attachments. |
| **Calendar** | ✅ Full | Manage events, recurring events, reminders, attendees via CalDAV. |
| **Contacts** | ✅ Full | CRUD operations for contacts and address books via CardDAV. |
| **Cookbook** | ✅ Full | Manage recipes with schema.org metadata. Import from URLs, search, categorize. |
| **Files (WebDAV)** | ✅ Full | Complete file system access - browse, read, write, organize files. |
| **Deck** | ✅ Full | Project management - boards, stacks, cards, labels, assignments. |
| **Tables** | ⚠️ Partial | Row-level operations. Table management not yet supported. |
| **Tasks** | ❌ Planned | [Issue #73](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/73) |
See our [detailed comparison](docs/comparison-context-agent.md) for architecture diagrams, workflow examples, and guidance on when to use each approach.
Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request!
@@ -30,14 +41,15 @@ Want to see another Nextcloud app supported? [Open an issue](https://github.com/
| Mode | Security | Best For |
|------|----------|----------|
| **OAuth2/OIDC** ⚠️ **Experimental** | 🔒 High | Testing, evaluation (requires patches) |
| **OAuth2/OIDC** ⚠️ **Experimental** | 🔒 High | Testing, evaluation (requires patch for app-specific APIs) |
| **Basic Auth** ✅ | Lower | Development, testing, production |
> [!IMPORTANT]
> **OAuth is experimental** and requires manual patches to upstream Nextcloud apps. Specifically:
> **OAuth is experimental** and requires a manual patch to the `user_oidc` app for full functionality:
> - **Required patch**: `user_oidc` app needs modifications for Bearer token support ([issue #1221](https://github.com/nextcloud/user_oidc/issues/1221))
> - **Impact**: Without the patch, most app-specific APIs (Notes, Calendar, Contacts, Deck, etc.) will fail with 401 errors
> - **Production use**: Wait for upstream patches to be merged into official releases
> - **What works without patches**: OAuth flow, PKCE support (with `oidc` v1.10.0+), OCS APIs
> - **Production use**: Wait for upstream patch to be merged into official releases
>
> See [OAuth Upstream Status](docs/oauth-upstream-status.md) for detailed information on required patches and workarounds.
@@ -92,10 +104,10 @@ See [Configuration Guide](docs/configuration.md) for all options.
3. Start the server
**OAuth Setup (experimental):**
1. Install Nextcloud OIDC apps (`oidc` + `user_oidc`)
2. **Apply required patches** to `user_oidc` app (see [OAuth Upstream Status](docs/oauth-upstream-status.md))
3. Enable dynamic client registration
4. Configure Bearer token validation
1. Install Nextcloud OIDC apps (`oidc` v1.10.0+ + `user_oidc`)
2. **Apply required patch** to `user_oidc` app for Bearer token support (see [OAuth Upstream Status](docs/oauth-upstream-status.md))
3. Enable dynamic client registration or create an OIDC client with id & secret
4. Configure Bearer token validation in `user_oidc`
5. Start the server
See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth Setup Guide](docs/oauth-setup.md) for detailed instructions.
@@ -1,63 +0,0 @@
From 9036daecdc8bcdf8114715dcf17e5c06967b25fb Mon Sep 17 00:00:00 2001
From: Chris Coutinho <chris@coutinho.io>
Date: Mon, 13 Oct 2025 23:24:53 +0200
Subject: [PATCH 1/2] feat: Advertise PKCE support in discovery document
Add code_challenge_methods_supported to OpenID Connect discovery
document when PKCE is enabled via proof_key_for_code_exchange config.
Rationale:
According to RFC 8414 Section 2, the code_challenge_methods_supported
field in OAuth 2.0 Authorization Server Metadata has specific semantics:
"If omitted, the authorization server does not support PKCE."
This means that clients following RFC 8414 strictly will interpret the
absence of this field as explicit non-support for PKCE, even if the
authorization server technically supports it.
Impact:
- Standards-compliant OAuth clients (e.g., MCP clients) require explicit
advertisement of PKCE support before proceeding with authorization
- The MCP (Model Context Protocol) specification mandates that clients
MUST refuse to proceed if code_challenge_methods_supported is absent
- Other security-focused OAuth implementations may have similar checks
Implementation:
- Only advertises S256 (SHA-256) challenge method, which is the most
secure and widely supported method
- Conditional on the existing proof_key_for_code_exchange app config
- Maintains backward compatibility: only added when PKCE is enabled
This change ensures the discovery document accurately reflects server
capabilities per RFC 8414 semantics, enabling compatibility with
strict standards-compliant OAuth clients.
References:
- RFC 8414: OAuth 2.0 Authorization Server Metadata
- RFC 7636: Proof Key for Code Exchange by OAuth Public Clients
- MCP Authorization Specification
Signed-off-by: Chris Coutinho <chris@coutinho.io>
---
lib/Util/DiscoveryGenerator.php | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/lib/Util/DiscoveryGenerator.php b/lib/Util/DiscoveryGenerator.php
index ee3cd57..6429f94 100644
--- a/lib/Util/DiscoveryGenerator.php
+++ b/lib/Util/DiscoveryGenerator.php
@@ -171,6 +171,11 @@ class DiscoveryGenerator
$discoveryPayload['registration_endpoint'] = $host . $this->urlGenerator->linkToRoute('oidc.DynamicRegistration.registerClient', []);
}
+ // Add PKCE support if enabled
+ if ($this->appConfig->getAppValueBool('proof_key_for_code_exchange', false)) {
+ $discoveryPayload['code_challenge_methods_supported'] = ['S256'];
+ }
+
$this->logger->info('Request to Discovery Endpoint.');
$response = new JSONResponse($discoveryPayload);
--
2.51.1
@@ -1,320 +0,0 @@
From cb2c931fe1f73e5bbfdf459928b5b21e2d96e0f1 Mon Sep 17 00:00:00 2001
From: Chris Coutinho <chris@coutinho.io>
Date: Sun, 19 Oct 2025 21:04:46 +0200
Subject: [PATCH 2/2] Initial implementation of PKCE
Signed-off-by: Chris Coutinho <chris@coutinho.io>
---
lib/Controller/LoginRedirectorController.php | 44 +++++++++++-
lib/Controller/OIDCApiController.php | 68 ++++++++++++++++++-
lib/Db/AccessToken.php | 10 +++
.../Version0014Date20251019100100.php | 63 +++++++++++++++++
lib/Util/DiscoveryGenerator.php | 2 +-
5 files changed, 184 insertions(+), 3 deletions(-)
create mode 100644 lib/Migration/Version0014Date20251019100100.php
diff --git a/lib/Controller/LoginRedirectorController.php b/lib/Controller/LoginRedirectorController.php
index 1b9bdde..5f2d327 100644
--- a/lib/Controller/LoginRedirectorController.php
+++ b/lib/Controller/LoginRedirectorController.php
@@ -142,6 +142,8 @@ class LoginRedirectorController extends ApiController
* @param string $scope
* @param string $nonce
* @param string $resource
+ * @param string $code_challenge
+ * @param string $code_challenge_method
* @return Response
*/
#[BruteForceProtection(action: 'oidc_login')]
@@ -155,7 +157,9 @@ class LoginRedirectorController extends ApiController
$redirect_uri,
$scope,
$nonce,
- $resource
+ $resource,
+ $code_challenge = null,
+ $code_challenge_method = null
): Response
{
if (!$this->userSession->isLoggedIn()) {
@@ -168,6 +172,8 @@ class LoginRedirectorController extends ApiController
$this->session->set('oidc_scope', $scope);
$this->session->set('oidc_nonce', $nonce);
$this->session->set('oidc_resource', $resource);
+ $this->session->set('oidc_code_challenge', $code_challenge);
+ $this->session->set('oidc_code_challenge_method', $code_challenge_method);
$afterLoginRedirectUrl = $this->urlGenerator->linkToRoute('oidc.Page.index', []);
@@ -204,6 +210,12 @@ class LoginRedirectorController extends ApiController
if (empty($resource)) {
$resource = $this->session->get('oidc_resource');
}
+ if (empty($code_challenge)) {
+ $code_challenge = $this->session->get('oidc_code_challenge');
+ }
+ if (empty($code_challenge_method)) {
+ $code_challenge_method = $this->session->get('oidc_code_challenge_method');
+ }
// Set default scope if scope is not set at all
if (!isset($scope)) {
@@ -327,6 +339,30 @@ class LoginRedirectorController extends ApiController
$uid = $this->userSession->getUser()->getUID();
+ // PKCE validation (RFC 7636)
+ if (!empty($code_challenge)) {
+ // Validate code_challenge format: 43-128 characters, unreserved chars only
+ if (!preg_match('/^[A-Za-z0-9._~-]{43,128}$/', $code_challenge)) {
+ $this->logger->notice('Invalid code_challenge format for client ' . $client_id . '.');
+ $url = $redirect_uri . '?error=invalid_request&error_description=Invalid%20code_challenge%20format&state=' . urlencode($state);
+ return new RedirectResponse($url);
+ }
+
+ // Default to S256 if method not specified
+ if (empty($code_challenge_method)) {
+ $code_challenge_method = 'S256';
+ }
+
+ // Validate code_challenge_method: only S256 and plain are allowed
+ if (!in_array($code_challenge_method, ['S256', 'plain'])) {
+ $this->logger->notice('Unsupported code_challenge_method for client ' . $client_id . ': ' . $code_challenge_method);
+ $url = $redirect_uri . '?error=invalid_request&error_description=Unsupported%20code_challenge_method&state=' . urlencode($state);
+ return new RedirectResponse($url);
+ }
+
+ $this->logger->debug('PKCE challenge received for client ' . $client_id . ' using method ' . $code_challenge_method);
+ }
+
$code = $this->random->generate(128, ISecureRandom::CHAR_UPPER.ISecureRandom::CHAR_LOWER.ISecureRandom::CHAR_DIGITS);
$accessToken = new AccessToken();
$accessToken->setClientId($client->getId());
@@ -343,6 +379,12 @@ class LoginRedirectorController extends ApiController
}
$accessToken->setNonce($nonce);
+ // Store PKCE challenge if provided
+ if (!empty($code_challenge)) {
+ $accessToken->setCodeChallenge(substr($code_challenge, 0, 128));
+ $accessToken->setCodeChallengeMethod(substr($code_challenge_method, 0, 16));
+ }
+
try {
$accessToken->setAccessToken($this->jwtGenerator->generateAccessToken($accessToken, $client, $this->request->getServerProtocol(), $this->request->getServerHost()));
$this->accessTokenMapper->insert($accessToken);
diff --git a/lib/Controller/OIDCApiController.php b/lib/Controller/OIDCApiController.php
index 6fd6eb0..059396c 100644
--- a/lib/Controller/OIDCApiController.php
+++ b/lib/Controller/OIDCApiController.php
@@ -125,12 +125,13 @@ class OIDCApiController extends ApiController {
* @param string $refresh_token
* @param string $client_id
* @param string $client_secret
+ * @param string $code_verifier
* @return JSONResponse
*/
#[BruteForceProtection(action: 'oidc_token')]
#[PublicPage]
#[NoCSRFRequired]
- public function getToken($grant_type, $code, $refresh_token, $client_id, $client_secret): JSONResponse
+ public function getToken($grant_type, $code, $refresh_token, $client_id, $client_secret, $code_verifier = null): JSONResponse
{
$expireTime = (int)$this->appConfig->getAppValueString(Application::APP_CONFIG_DEFAULT_EXPIRE_TIME, '0');
$refreshExpireTime = (int)$this->appConfig->getAppValueString(Application::APP_CONFIG_DEFAULT_REFRESH_EXPIRE_TIME, Application::DEFAULT_REFRESH_EXPIRE_TIME);
@@ -212,6 +213,32 @@ class OIDCApiController extends ApiController {
'error_description' => 'Access token already expired.',
], Http::STATUS_BAD_REQUEST);
}
+
+ // PKCE verification (RFC 7636 Section 4.6)
+ $storedCodeChallenge = $accessToken->getCodeChallenge();
+ if (!empty($storedCodeChallenge)) {
+ // PKCE was used in authorization request, code_verifier is required
+ if (empty($code_verifier)) {
+ $this->accessTokenMapper->delete($accessToken);
+ $this->logger->notice('Missing code_verifier for PKCE-protected token. Client id: ' . $client_id);
+ return new JSONResponse([
+ 'error' => 'invalid_grant',
+ 'error_description' => 'code_verifier required for PKCE flow.',
+ ], Http::STATUS_BAD_REQUEST);
+ }
+
+ $storedCodeChallengeMethod = $accessToken->getCodeChallengeMethod() ?: 'S256';
+ if (!$this->verifyPkce($code_verifier, $storedCodeChallenge, $storedCodeChallengeMethod)) {
+ $this->accessTokenMapper->delete($accessToken);
+ $this->logger->notice('PKCE verification failed. Client id: ' . $client_id);
+ return new JSONResponse([
+ 'error' => 'invalid_grant',
+ 'error_description' => 'Invalid code_verifier.',
+ ], Http::STATUS_BAD_REQUEST);
+ }
+
+ $this->logger->debug('PKCE verification successful for client ' . $client_id);
+ }
} elseif ($refreshExpireTime !== 'never') {
// The refresh token must not be expired
$refreshExpireTime = (int)$refreshExpireTime;
@@ -286,4 +313,43 @@ class OIDCApiController extends ApiController {
return $response;
}
+
+ /**
+ * Base64URL encode (RFC 7636 Section 4.2)
+ *
+ * @param string $data
+ * @return string
+ */
+ private function base64UrlEncode(string $data): string
+ {
+ return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
+ }
+
+ /**
+ * Verify PKCE code_verifier against code_challenge (RFC 7636 Section 4.6)
+ *
+ * @param string $codeVerifier
+ * @param string $codeChallenge
+ * @param string $codeChallengeMethod
+ * @return bool
+ */
+ private function verifyPkce(string $codeVerifier, string $codeChallenge, string $codeChallengeMethod): bool
+ {
+ // Validate code_verifier format: 43-128 characters, unreserved chars only
+ if (!preg_match('/^[A-Za-z0-9._~-]{43,128}$/', $codeVerifier)) {
+ return false;
+ }
+
+ // Compute the challenge based on the method
+ if ($codeChallengeMethod === 'S256') {
+ $computedChallenge = $this->base64UrlEncode(hash('sha256', $codeVerifier, true));
+ } elseif ($codeChallengeMethod === 'plain') {
+ $computedChallenge = $codeVerifier;
+ } else {
+ return false;
+ }
+
+ // Constant-time comparison to prevent timing attacks
+ return hash_equals($codeChallenge, $computedChallenge);
+ }
}
diff --git a/lib/Db/AccessToken.php b/lib/Db/AccessToken.php
index a0419c0..593c5c8 100644
--- a/lib/Db/AccessToken.php
+++ b/lib/Db/AccessToken.php
@@ -27,6 +27,10 @@ use OCP\AppFramework\Db\Entity;
* @method void setNonce(string $nonce)
* @method string getResource()
* @method void setResource(string $resource)
+ * @method string getCodeChallenge()
+ * @method void setCodeChallenge(string $codeChallenge)
+ * @method string getCodeChallengeMethod()
+ * @method void setCodeChallengeMethod(string $codeChallengeMethod)
*/
class AccessToken extends Entity
{
@@ -50,6 +54,10 @@ class AccessToken extends Entity
protected $nonce;
/** @var string */
protected $resource;
+ /** @var string */
+ protected $codeChallenge;
+ /** @var string */
+ protected $codeChallengeMethod;
public function __construct() {
$this->addType('id', 'int');
@@ -62,5 +70,7 @@ class AccessToken extends Entity
$this->addType('refreshed', 'int');
$this->addType('nonce', 'string');
$this->addType('resource', 'string');
+ $this->addType('codeChallenge', 'string');
+ $this->addType('codeChallengeMethod', 'string');
}
}
diff --git a/lib/Migration/Version0014Date20251019100100.php b/lib/Migration/Version0014Date20251019100100.php
new file mode 100644
index 0000000..bf705b3
--- /dev/null
+++ b/lib/Migration/Version0014Date20251019100100.php
@@ -0,0 +1,63 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2022-2025 Thorsten Jagel <dev@jagel.net>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\OIDCIdentityProvider\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+use Psr\Log\LoggerInterface;
+use OCP\IDBConnection;
+use OCP\DB\Types;
+
+class Version0014Date20251019100100 extends SimpleMigrationStep {
+ private LoggerInterface $logger;
+ private IDBConnection $db;
+
+ public function __construct(
+ IDBConnection $db,
+ LoggerInterface $logger
+ )
+ {
+ $this->db = $db;
+ $this->logger = $logger;
+ }
+
+ /**
+ * @param IOutput $output
+ * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
+ * @param array $options
+ * @return null|ISchemaWrapper
+ */
+ public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) {
+ /** @var ISchemaWrapper $schema */
+ $schema = $schemaClosure();
+
+ $table = $schema->getTable('oidc_access_tokens');
+
+ if(!$table->hasColumn('code_challenge')) {
+ $table->addColumn('code_challenge', Types::STRING, [
+ 'notnull' => false,
+ 'default' => null,
+ 'length' => 128,
+ ]);
+ }
+
+ if(!$table->hasColumn('code_challenge_method')) {
+ $table->addColumn('code_challenge_method', Types::STRING, [
+ 'notnull' => false,
+ 'default' => null,
+ 'length' => 16,
+ ]);
+ }
+
+ return $schema;
+ }
+
+}
diff --git a/lib/Util/DiscoveryGenerator.php b/lib/Util/DiscoveryGenerator.php
index 6429f94..d96a18c 100644
--- a/lib/Util/DiscoveryGenerator.php
+++ b/lib/Util/DiscoveryGenerator.php
@@ -173,7 +173,7 @@ class DiscoveryGenerator
// Add PKCE support if enabled
if ($this->appConfig->getAppValueBool('proof_key_for_code_exchange', false)) {
- $discoveryPayload['code_challenge_methods_supported'] = ['S256'];
+ $discoveryPayload['code_challenge_methods_supported'] = ['S256', 'plain'];
}
$this->logger->info('Request to Discovery Endpoint.');
--
2.51.1
@@ -11,8 +11,6 @@ php /var/www/html/occ app:enable oidc
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
patch -d /var/www/html/custom_apps/oidc -p1 < /docker-entrypoint-hooks.d/post-installation/0001-feat-Advertise-PKCE-support-in-discovery-document.patch
patch -d /var/www/html/custom_apps/oidc -p1 < /docker-entrypoint-hooks.d/post-installation/0002-Initial-implementation-of-PKCE.patch
# Configure OIDC Identity Provider with dynamic client registration enabled
php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true'
+42 -26
View File
@@ -44,36 +44,52 @@ This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`.
---
### 2. PKCE Support Advertisement in Discovery
### 2. PKCE Support (RFC 7636)
**Status**: 🟢 **PR Submitted** (Pending Review)
**Status**: **Complete** (Merged Upstream)
**Affected Component**: `oidc` app
**Issue**: The OIDC discovery endpoint (`/.well-known/openid-configuration`) does not advertise PKCE support in the `code_challenge_methods_supported` field.
**Issue**: The OIDC app lacked PKCE (Proof Key for Code Exchange) implementation per RFC 7636.
**Why It Matters**:
- MCP specification requires PKCE with S256 code challenge method
- RFC 8414 states that absence of `code_challenge_methods_supported` means PKCE is **not supported**
- Some MCP clients may reject providers without proper PKCE advertisement
**Resolution**: Full PKCE support has been implemented and merged upstream into the `oidc` app:
**Current Behavior**:
- PKCE **functionally works** (the OIDC app accepts and validates PKCE)
- PKCE just isn't **advertised** in discovery metadata
**Authorization Endpoint** (`/authorize`):
- Accepts `code_challenge` and `code_challenge_method` parameters
- Validates code_challenge format (43-128 characters, unreserved chars only)
- Supports both `S256` (SHA-256) and `plain` challenge methods
- Stores challenge and method in database for later verification
**Recommended Fix**: Update `oidc` app to include:
**Token Endpoint** (`/token`):
- Accepts `code_verifier` parameter
- Verifies code_verifier against stored code_challenge using proper algorithm
- Uses constant-time comparison to prevent timing attacks
- Enforces code_verifier requirement when PKCE was used in authorization
**Discovery Document**:
```json
{
"code_challenge_methods_supported": ["S256"]
"code_challenge_methods_supported": ["S256", "plain"]
}
```
**Workaround**: The MCP server implements PKCE validation and logs a warning if not advertised. Functionality still works.
**Database**:
- New columns: `code_challenge` and `code_challenge_method` in `oc_oauth2_access_tokens`
- Migration included for existing installations
**Upstream PR**: [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) - Submitted 2025-10-13
- **Changes**: Adds `code_challenge_methods_supported: ["S256"]` to discovery document when PKCE is enabled
- **Size**: +5 lines added, 0 deleted
- **Status**: Open, awaiting review
**Why It Mattered**:
- MCP specification requires PKCE with S256 code challenge method
- RFC 7636 PKCE provides security for public clients (no client secret)
- RFC 8414 states that absence of `code_challenge_methods_supported` means PKCE is **not supported**
- Prevents authorization code interception attacks
**Upstream PR**: [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) - ✅ **Merged 2025-10-20**
- **Changes**: Complete PKCE implementation (+194 lines)
- Authorization flow with code_challenge validation
- Token exchange with code_verifier verification
- Database schema updates
- Discovery document updates
- **Status**: Merged and available in v1.10.0+ of the `oidc` app
---
@@ -82,17 +98,17 @@ This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`.
| PR/Issue | Component | Status | Priority | Notes |
|----------|-----------|--------|----------|-------|
| [user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221) | `user_oidc` | 🟡 Open | High | Required for app-specific APIs |
| [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) | `oidc` | 🟢 PR Open | Medium | PKCE advertisement for standards compliance |
| [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) | `oidc` | ✅ Merged | ~~Medium~~ | ✅ PKCE advertisement complete (v1.10.0+) |
## What Works Without Patches
The following functionality works **out of the box** without any patches:
**OAuth Flow**:
- OIDC discovery
- OIDC discovery with full PKCE support (requires `oidc` app v1.10.0+)
- Dynamic client registration
- Authorization code flow with PKCE
- Token exchange
- Authorization code flow with PKCE (S256 and plain methods)
- Token exchange with code_verifier verification
- Userinfo endpoint
**MCP Server as Resource Server**:
@@ -116,9 +132,9 @@ The following functionality requires upstream patches:
- Tables API
- Custom app APIs
🟡 **Standards Compliance** (PKCE advertisement):
- Full RFC 8414 compliance
- MCP client compatibility guarantee
**Standards Compliance**: Now complete with `oidc` app v1.10.0+
- Full RFC 8414 compliance (PKCE advertisement)
- MCP client compatibility guarantee
## Installation Instructions
@@ -221,6 +237,6 @@ Want to help get these patches merged?
---
**Last Updated**: 2025-10-14
**Last Updated**: 2025-10-20
**Next Review**: When PR #584 or issue #1221 has activity
**Next Review**: When issue #1221 (Bearer token support) has activity
@@ -1,5 +1,6 @@
"""Dynamic client registration for Nextcloud OIDC."""
import datetime as dt
import json
import logging
import os
@@ -113,8 +114,11 @@ async def register_client(
logger.info(
f"Successfully registered client: {client_info.get('client_id')}"
)
expires_at = dt.datetime.fromtimestamp(
client_info.get("client_secret_expires_at")
)
logger.info(
f"Client expires at: {client_info.get('client_secret_expires_at')} "
f"Client expires at: {expires_at} "
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
)
+7 -5
View File
@@ -260,7 +260,7 @@ class CalendarClient:
result = []
for event in events:
await event.load()
await event.load(only_if_unloaded=True)
event_dict = self._parse_ical_event(event.data)
if event_dict:
event_dict["href"] = str(event.url)
@@ -311,7 +311,7 @@ class CalendarClient:
# Find the event by UID using caldav library
event = await calendar.event_by_uid(event_uid)
await event.load()
await event.load(only_if_unloaded=True)
# Merge updates into existing iCal data
updated_ical = self._merge_ical_properties(event.data, event_data, event_uid)
@@ -347,7 +347,7 @@ class CalendarClient:
calendar = self._get_calendar(calendar_name)
event = await calendar.event_by_uid(event_uid)
await event.load()
await event.load(only_if_unloaded=True)
event_data = self._parse_ical_event(event.data)
if not event_data:
@@ -413,7 +413,9 @@ class CalendarClient:
result = []
for todo in todos:
await todo.load()
# Only load if data not already present from REPORT response
# This avoids 404 errors for virtual calendars (e.g., Deck boards)
await todo.load(only_if_unloaded=True)
todo_dict = self._parse_ical_todo(todo.data)
if todo_dict:
todo_dict["href"] = str(todo.url)
@@ -465,7 +467,7 @@ class CalendarClient:
try:
# Find the todo by UID
todo = await calendar.todo_by_uid(todo_uid)
await todo.load()
await todo.load(only_if_unloaded=True)
logger.debug(
f"Loaded todo {todo_uid}, current data length: {len(todo.data)}"
+37 -7
View File
@@ -1,12 +1,14 @@
[project]
name = "nextcloud-mcp-server"
version = "0.16.0"
description = ""
version = "0.17.0"
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
authors = [
{name = "Chris Coutinho",email = "chris@coutinho.io"}
{name = "Chris Coutinho", email = "chris@coutinho.io"}
]
readme = "README.md"
license = {text = "AGPL-3.0-only"}
requires-python = ">=3.11"
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
dependencies = [
"mcp[cli] (>=1.18,<1.19)",
"httpx (>=0.28.1,<0.29.0)",
@@ -17,13 +19,31 @@ dependencies = [
"click>=8.1.8",
"caldav",
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Affero General Public License v3",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Topic :: Communications",
"Topic :: Internet :: WWW/HTTP",
]
[project.urls]
Homepage = "https://github.com/cbcoutinho/nextcloud-mcp-server"
Documentation = "https://github.com/cbcoutinho/nextcloud-mcp-server#readme"
Repository = "https://github.com/cbcoutinho/nextcloud-mcp-server"
"Bug Tracker" = "https://github.com/cbcoutinho/nextcloud-mcp-server/issues"
Changelog = "https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/CHANGELOG.md"
[tool.pytest.ini_options]
anyio_mode = "auto"
addopts = "-p no:asyncio" # Disable pytest-asyncio plugin, use only anyio
log_cli = 1
log_cli_level = "WARN"
log_level = "WARN"
log_cli_level = "ERROR"
log_level = "ERROR"
markers = [
"integration: marks tests as slow (deselect with '-m \"not slow\"')",
"oauth: marks tests as oauth (deselect with '-m \"not oauth\"')"
@@ -50,8 +70,12 @@ extend-select = ["I"]
caldav = { git = "https://github.com/cbcoutinho/caldav", branch = "feature/httpx" }
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
requires = ["uv_build>=0.9.4,<0.10.0"]
build-backend = "uv_build"
[tool.uv.build-backend]
module-name = "nextcloud_mcp_server"
module-root = ""
[dependency-groups]
dev = [
@@ -67,3 +91,9 @@ dev = [
[project.scripts]
nextcloud-mcp-server = "nextcloud_mcp_server.app:run"
[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
publish-url = "https://test.pypi.org/legacy/"
explicit = true
+1 -1
View File
@@ -829,7 +829,7 @@ async def shared_oauth_client_credentials(anyio_backend, oauth_callback_server):
nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint,
storage_path=".nextcloud_oauth_shared_test_client.json",
client_name="Nextcloud MCP Server - Shared Test Client",
client_name="Pytest - Shared Test Client",
redirect_uris=[callback_url],
)
Generated
+3 -3
View File
@@ -54,8 +54,8 @@ wheels = [
[[package]]
name = "caldav"
version = "2.0.2.dev36+g2ac7492e5"
source = { git = "https://github.com/cbcoutinho/caldav?branch=feature%2Fhttpx#2ac7492e5b1005bdc7de78ce5fdc03b22449a806" }
version = "2.0.2.dev38+g1aa2be35e"
source = { git = "https://github.com/cbcoutinho/caldav?branch=feature%2Fhttpx#1aa2be35e94883b44efd42f1cd82d281f8f58e60" }
dependencies = [
{ name = "httpx", extra = ["http2"] },
{ name = "icalendar" },
@@ -799,7 +799,7 @@ wheels = [
[[package]]
name = "nextcloud-mcp-server"
version = "0.16.0"
version = "0.17.0"
source = { editable = "." }
dependencies = [
{ name = "caldav" },