Compare commits

...

6 Commits

Author SHA1 Message Date
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
7 changed files with 21 additions and 391 deletions
+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
@@ -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'
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.16.0"
version = "0.17.0"
description = ""
authors = [
{name = "Chris Coutinho",email = "chris@coutinho.io"}
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.dev37+g543d3829b"
source = { git = "https://github.com/cbcoutinho/caldav?branch=feature%2Fhttpx#543d3829b3caedadd9d3d52b91c01fd9f73cce02" }
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" },