From cd5a08ba607875b4e6e98f5f40e6e11d1b4aa615 Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Wed, 1 Oct 2025 14:53:08 +0200
Subject: [PATCH 01/25] gen ssl key pair and convert the public key to JWK
Signed-off-by: Julien Veyssier
---
appinfo/routes.php | 1 +
composer.json | 3 +-
composer.lock | 56 ++++++++++++++++++++++++++++++-
lib/Controller/ApiController.php | 14 ++++++++
lib/Service/JwkService.php | 57 ++++++++++++++++++++++++++++++++
5 files changed, 129 insertions(+), 2 deletions(-)
create mode 100644 lib/Service/JwkService.php
diff --git a/appinfo/routes.php b/appinfo/routes.php
index ad005f87..05584c8e 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -19,6 +19,7 @@
['name' => 'api#createUser', 'url' => '/user', 'verb' => 'POST'],
['name' => 'api#deleteUser', 'url' => '/user/{userId}', 'verb' => 'DELETE'],
+ ['name' => 'api#getJwks', 'url' => '/jwks', 'verb' => 'GET'],
['name' => 'id4me#showLogin', 'url' => '/id4me', 'verb' => 'GET'],
['name' => 'id4me#login', 'url' => '/id4me', 'verb' => 'POST'],
diff --git a/composer.json b/composer.json
index 49b50b80..287c3905 100644
--- a/composer.json
+++ b/composer.json
@@ -32,7 +32,8 @@
"require": {
"id4me/id4me-rp": "^1.2",
"firebase/php-jwt": "^7",
- "bamarni/composer-bin-plugin": "^1.4"
+ "bamarni/composer-bin-plugin": "^1.4",
+ "strobotti/php-jwk": "^1.3"
},
"require-dev": {
"nextcloud/coding-standard": "^1.0.0",
diff --git a/composer.lock b/composer.lock
index a03c9589..a1dc04f1 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "d04dc4373433d9ac88a4fd120a014533",
+ "content-hash": "2c4f937df9960ebd989efe94d706e93b",
"packages": [
{
"name": "bamarni/composer-bin-plugin",
@@ -280,6 +280,60 @@
}
],
"time": "2026-03-19T02:54:44+00:00"
+ },
+ {
+ "name": "strobotti/php-jwk",
+ "version": "v1.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Strobotti/php-jwk.git",
+ "reference": "a78580b55380f25bd8110452a5a031e36043551e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Strobotti/php-jwk/zipball/a78580b55380f25bd8110452a5a031e36043551e",
+ "reference": "a78580b55380f25bd8110452a5a031e36043551e",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-openssl": "*",
+ "php": ">=7.2.0",
+ "phpseclib/phpseclib": "^2.0"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^2.16",
+ "phpunit/phpunit": "^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Strobotti\\JWK\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Juha Jantunen",
+ "email": "juha@strobotti.com",
+ "homepage": "https://www.strobotti.com",
+ "role": "Developer"
+ }
+ ],
+ "description": "A small PHP library to handle JWKs (Json Web Keys)",
+ "homepage": "https://github.com/Strobotti/php-jwk",
+ "keywords": [
+ "JWK",
+ "JWKS"
+ ],
+ "support": {
+ "issues": "https://github.com/Strobotti/php-jwk/issues",
+ "source": "https://github.com/Strobotti/php-jwk/tree/master"
+ },
+ "time": "2020-04-01T03:22:04+00:00"
}
],
"packages-dev": [
diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php
index 28aaa6c7..53750d08 100644
--- a/lib/Controller/ApiController.php
+++ b/lib/Controller/ApiController.php
@@ -11,10 +11,12 @@
use OCA\UserOIDC\AppInfo\Application;
use OCA\UserOIDC\Db\UserMapper;
+use OCA\UserOIDC\Service\JwkService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
+use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\Files\IRootFolder;
use OCP\Files\NotPermittedException;
@@ -29,6 +31,7 @@ public function __construct(
private IRootFolder $root,
private UserMapper $userMapper,
private IUserManager $userManager,
+ private JwkService $jwkService,
) {
parent::__construct(Application::APP_ID, $request);
}
@@ -82,4 +85,15 @@ public function deleteUser(string $userId): DataResponse {
$user->delete();
return new DataResponse(['user_id' => $userId], Http::STATUS_OK);
}
+
+ #[NoCSRFRequired]
+ #[PublicPage]
+ public function getJwks(): DataResponse {
+ $jwks = $this->jwkService->getJwks();
+ return new DataResponse([
+ 'keys' => [
+ $jwks['public'],
+ ],
+ ]);
+ }
}
diff --git a/lib/Service/JwkService.php b/lib/Service/JwkService.php
new file mode 100644
index 00000000..877e8ecc
--- /dev/null
+++ b/lib/Service/JwkService.php
@@ -0,0 +1,57 @@
+createKeyPair();
+
+ $options = [
+ 'use' => 'sig',
+ 'alg' => 'sha512',
+ 'kid' => 'plop',
+ ];
+ $keyFactory = new KeyFactory();
+ $publicJwk = $keyFactory->createFromPem($keyPair['public'], $options);
+ // $privateJwk = $keyFactory->createFromPem($keyPair['private'], $options);
+ return [
+ 'public' => $publicJwk,
+ //'private' => $privateJwk,
+ ];
+ }
+
+ public function createKeyPair(): array {
+ $config = [
+ 'digest_alg' => 'sha512',
+ 'private_key_bits' => 4096,
+ 'private_key_type' => OPENSSL_KEYTYPE_RSA,
+ ];
+
+ // Create the private and public key
+ $key = openssl_pkey_new($config);
+ openssl_pkey_export($key, $privKeyPem);
+ $pubKey = openssl_pkey_get_details($key);
+ $pubKeyPem = $pubKey['key'];
+
+ return [
+ 'public' => $pubKeyPem,
+ 'private' => $privKeyPem,
+ ];
+ }
+}
From 18bbc9a8161622e3b15598d45e1e47e31d2c52d6 Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Thu, 9 Oct 2025 10:02:35 +0200
Subject: [PATCH 02/25] store pem priv key in appconfig, gen JWK from public
ssl key, test creating and decoding JWT from our key
Signed-off-by: Julien Veyssier
---
lib/Controller/ApiController.php | 16 ++--
lib/Service/JwkService.php | 125 ++++++++++++++++++++++++++-----
2 files changed, 115 insertions(+), 26 deletions(-)
diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php
index 53750d08..9d777c32 100644
--- a/lib/Controller/ApiController.php
+++ b/lib/Controller/ApiController.php
@@ -18,6 +18,7 @@
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\Http\JSONResponse;
use OCP\Files\IRootFolder;
use OCP\Files\NotPermittedException;
use OCP\IRequest;
@@ -88,12 +89,13 @@ public function deleteUser(string $userId): DataResponse {
#[NoCSRFRequired]
#[PublicPage]
- public function getJwks(): DataResponse {
- $jwks = $this->jwkService->getJwks();
- return new DataResponse([
- 'keys' => [
- $jwks['public'],
- ],
- ]);
+ public function getJwks(): JSONResponse {
+ try {
+ $jwk = $this->jwkService->getJwk();
+ return new JSONResponse(['keys' => [$jwk]]);
+ // return new JSONResponse($this->jwkService->debug());
+ } catch (\Exception|\Throwable $e) {
+ return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
}
}
diff --git a/lib/Service/JwkService.php b/lib/Service/JwkService.php
index 877e8ecc..bbbcbb3b 100644
--- a/lib/Service/JwkService.php
+++ b/lib/Service/JwkService.php
@@ -10,48 +10,135 @@
require_once __DIR__ . '/../../vendor/autoload.php';
+use OCA\UserOIDC\Vendor\Firebase\JWT\JWK;
+use OCA\UserOIDC\Vendor\Firebase\JWT\JWT;
+use OCP\AppFramework\Services\IAppConfig;
+use OCP\Exceptions\AppConfigTypeConflictException;
+use Strobotti\JWK\Key\KeyInterface;
use Strobotti\JWK\KeyFactory;
class JwkService {
+ public const PEM_PRIVATE_KEY_SETTINGS_KEY = 'pemPrivateKey';
+ public const PEM_PRIVATE_KEY_EXPIRES_AT_SETTINGS_KEY = 'pemPrivateKeyExpiresAt';
+ public const PEM_PRIVATE_KEY_EXPIRES_IN_SECONDS = 60 * 2;
+
public function __construct(
+ private IAppConfig $appConfig,
) {
-
}
- public function getJwks(): array {
- $keyPair = $this->createKeyPair();
+ /**
+ * Get our stored private PEM key (or regenerate it if it's expired)
+ *
+ * @param bool $refresh
+ * @return string
+ * @throws AppConfigTypeConflictException
+ */
+ public function getMyPemPrivateKey(bool $refresh = true): string {
+ $pemPrivateKey = $this->appConfig->getAppValueString(self::PEM_PRIVATE_KEY_SETTINGS_KEY, lazy: true);
+ $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_PRIVATE_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
- $options = [
- 'use' => 'sig',
- 'alg' => 'sha512',
- 'kid' => 'plop',
- ];
- $keyFactory = new KeyFactory();
- $publicJwk = $keyFactory->createFromPem($keyPair['public'], $options);
- // $privateJwk = $keyFactory->createFromPem($keyPair['private'], $options);
- return [
- 'public' => $publicJwk,
- //'private' => $privateJwk,
- ];
+ if ($pemPrivateKey === '' || $pemPrivateKeyExpiresAt === 0 || ($refresh && time() > $pemPrivateKeyExpiresAt)) {
+ $pemPrivateKey = $this->generatePemPrivateKey();
+ // store the key
+ $this->appConfig->setAppValueString(self::PEM_PRIVATE_KEY_SETTINGS_KEY, $pemPrivateKey, lazy: true);
+ $this->appConfig->setAppValueInt(self::PEM_PRIVATE_KEY_EXPIRES_AT_SETTINGS_KEY, time() + self::PEM_PRIVATE_KEY_EXPIRES_IN_SECONDS, lazy: true);
+ }
+ return $pemPrivateKey;
}
- public function createKeyPair(): array {
+ /**
+ * Generate a new full/private key and return it in PEM format
+ *
+ * @return string
+ */
+ public function generatePemPrivateKey(): string {
$config = [
'digest_alg' => 'sha512',
'private_key_bits' => 4096,
+ // 'private_key_type' => OPENSSL_KEYTYPE_EC,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
];
// Create the private and public key
$key = openssl_pkey_new($config);
openssl_pkey_export($key, $privKeyPem);
- $pubKey = openssl_pkey_get_details($key);
+
+ return $privKeyPem;
+ }
+
+ /**
+ * Get our stored private PEM key (or regenerate it if it's expired)
+ * Extract the public key from the full/private key
+ * Build a JWK from the public key
+ *
+ * @return array
+ * @throws AppConfigTypeConflictException
+ */
+ public function getJwk(): array {
+ $myPemPrivateKey = $this->getMyPemPrivateKey();
+ $sslPrivateKey = openssl_pkey_get_private($myPemPrivateKey);
+ $sslPublicKey = openssl_pkey_get_details($sslPrivateKey);
+ $pubKeyPem = $sslPublicKey['key'];
+ return $this->getJwkFromPem($pubKeyPem)->jsonSerialize();
+ }
+
+ /**
+ * Build a JWK from a PEM (public) key
+ *
+ * @param string $pemKey
+ * @return KeyInterface
+ * @throws AppConfigTypeConflictException
+ */
+ public function getJwkFromPem(string $pemKey): KeyInterface {
+ $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_PRIVATE_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
+ $options = [
+ 'use' => 'sig',
+ 'alg' => 'RS512',
+ 'kid' => 'key_' . $pemPrivateKeyExpiresAt,
+ ];
+ $keyFactory = new KeyFactory();
+ return $keyFactory->createFromPem($pemKey, $options);
+ }
+
+ /**
+ * Create a JWT token signed with a given private SSL key
+ *
+ * @param array $payload
+ * @param \OpenSSLAsymmetricKey $key
+ * @param string $keyId
+ * @param string $alg
+ * @return string
+ */
+ public function createJwt(array $payload, \OpenSSLAsymmetricKey $key, string $keyId, string $alg = 'RS512'): string {
+ return JWT::encode($payload, $key, $alg, $keyId);
+ }
+
+ public function debug(): array {
+ $myPemPrivateKey = $this->getMyPemPrivateKey();
+ $sslPrivateKey = openssl_pkey_get_private($myPemPrivateKey);
+ $pubKey = openssl_pkey_get_details($sslPrivateKey);
$pubKeyPem = $pubKey['key'];
+ $payload = ['lll' => 'aaa'];
+ $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_PRIVATE_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
+ $signedJwtToken = $this->createJwt($payload, $sslPrivateKey, 'key_' . $pemPrivateKeyExpiresAt, 'RS512');
+
+ // check content of JWT
+ $rawJwks = ['keys' => [$this->getJwkFromPem($pubKeyPem)->jsonSerialize()]];
+ $jwks = JWK::parseKeySet($rawJwks, 'RS512');
+ $jwtPayload = JWT::decode($signedJwtToken, $jwks);
+ $jwtPayloadArray = json_decode(json_encode($jwtPayload), true);
+
return [
- 'public' => $pubKeyPem,
- 'private' => $privKeyPem,
+ 'public_jwk' => $this->getJwkFromPem($pubKeyPem)->jsonSerialize(),
+ 'public_pem' => $pubKeyPem,
+ 'private_pem' => $myPemPrivateKey,
+ 'payload' => $payload,
+ 'signed_jwt' => $signedJwtToken,
+ 'jwt_payload' => $jwtPayloadArray,
+ 'arrays_are_equal' => $payload === $jwtPayloadArray,
];
}
}
From 279918559836959c9d34c276db97aefcb26ce7f6 Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Tue, 28 Oct 2025 19:09:29 +0100
Subject: [PATCH 03/25] use EC signature key with P-384 curve
Signed-off-by: Julien Veyssier
---
lib/Service/JwkService.php | 63 ++++++++++++++++++++++++++++++++------
1 file changed, 53 insertions(+), 10 deletions(-)
diff --git a/lib/Service/JwkService.php b/lib/Service/JwkService.php
index bbbcbb3b..97d22d14 100644
--- a/lib/Service/JwkService.php
+++ b/lib/Service/JwkService.php
@@ -55,17 +55,20 @@ public function getMyPemPrivateKey(bool $refresh = true): string {
*/
public function generatePemPrivateKey(): string {
$config = [
- 'digest_alg' => 'sha512',
- 'private_key_bits' => 4096,
- // 'private_key_type' => OPENSSL_KEYTYPE_EC,
- 'private_key_type' => OPENSSL_KEYTYPE_RSA,
+ // 'digest_alg' => 'sha512',
+ // 'private_key_bits' => 4096,
+ 'private_key_type' => OPENSSL_KEYTYPE_EC,
+ // 'private_key_type' => OPENSSL_KEYTYPE_RSA,
+ // 'curve_name' => 'secp256r1',
+ 'curve_name' => 'secp384r1',
+ // 'curve_name' => 'secp521r1',
];
// Create the private and public key
$key = openssl_pkey_new($config);
- openssl_pkey_export($key, $privKeyPem);
+ openssl_pkey_export($key, $privateKeyPem);
- return $privKeyPem;
+ return $privateKeyPem;
}
/**
@@ -80,8 +83,21 @@ public function getJwk(): array {
$myPemPrivateKey = $this->getMyPemPrivateKey();
$sslPrivateKey = openssl_pkey_get_private($myPemPrivateKey);
$sslPublicKey = openssl_pkey_get_details($sslPrivateKey);
- $pubKeyPem = $sslPublicKey['key'];
- return $this->getJwkFromPem($pubKeyPem)->jsonSerialize();
+ return $this->getSigJwkFromSslKey($sslPublicKey);
+ // $pubKeyPem = $sslPublicKey['key'];
+ // return $this->getJwkFromPem($pubKeyPem)->jsonSerialize();
+ }
+
+ public function getSigJwkFromSslKey(array $sslKey): array {
+ $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_PRIVATE_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
+ return [
+ 'kty' => 'EC',
+ 'use' => 'sig',
+ 'kid' => 'sig_key_' . $pemPrivateKeyExpiresAt,
+ 'crv' => 'P-384',
+ 'x' => \rtrim(\strtr(\base64_encode($sslKey['ec']['x']), '+/', '-_'), '='),
+ 'y' => \rtrim(\strtr(\base64_encode($sslKey['ec']['y']), '+/', '-_'), '='),
+ ];
}
/**
@@ -96,7 +112,7 @@ public function getJwkFromPem(string $pemKey): KeyInterface {
$options = [
'use' => 'sig',
'alg' => 'RS512',
- 'kid' => 'key_' . $pemPrivateKeyExpiresAt,
+ 'kid' => 'sig_key_' . $pemPrivateKeyExpiresAt,
];
$keyFactory = new KeyFactory();
return $keyFactory->createFromPem($pemKey, $options);
@@ -123,7 +139,34 @@ public function debug(): array {
$payload = ['lll' => 'aaa'];
$pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_PRIVATE_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
- $signedJwtToken = $this->createJwt($payload, $sslPrivateKey, 'key_' . $pemPrivateKeyExpiresAt, 'RS512');
+ $signedJwtToken = $this->createJwt($payload, $sslPrivateKey, 'sig_key_' . $pemPrivateKeyExpiresAt, 'RS512');
+
+ // check content of JWT
+ $rawJwks = ['keys' => [$this->getSigJwkFromSslKey($pubKey)]];
+ $jwks = JWK::parseKeySet($rawJwks, 'RS512');
+ $jwtPayload = JWT::decode($signedJwtToken, $jwks);
+ $jwtPayloadArray = json_decode(json_encode($jwtPayload), true);
+
+ return [
+ 'public_jwk' => $this->getSigJwkFromSslKey($pubKey),
+ 'public_pem' => $pubKeyPem,
+ 'private_pem' => $myPemPrivateKey,
+ 'payload' => $payload,
+ 'signed_jwt' => $signedJwtToken,
+ 'jwt_payload' => $jwtPayloadArray,
+ 'arrays_are_equal' => $payload === $jwtPayloadArray,
+ ];
+ }
+
+ public function debugRSA(): array {
+ $myPemPrivateKey = $this->getMyPemPrivateKey();
+ $sslPrivateKey = openssl_pkey_get_private($myPemPrivateKey);
+ $pubKey = openssl_pkey_get_details($sslPrivateKey);
+ $pubKeyPem = $pubKey['key'];
+
+ $payload = ['lll' => 'aaa'];
+ $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_PRIVATE_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
+ $signedJwtToken = $this->createJwt($payload, $sslPrivateKey, 'sig_key_' . $pemPrivateKeyExpiresAt);
// check content of JWT
$rawJwks = ['keys' => [$this->getJwkFromPem($pubKeyPem)->jsonSerialize()]];
From 7aa458f42ff3020ec6aa867d26c8f5953c2f5b44 Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Wed, 29 Oct 2025 11:33:25 +0100
Subject: [PATCH 04/25] generate/store/refresh encryption key
Signed-off-by: Julien Veyssier
---
lib/Controller/ApiController.php | 4 +-
lib/Service/JwkService.php | 112 +++++++++++++++++++++----------
2 files changed, 78 insertions(+), 38 deletions(-)
diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php
index 9d777c32..c4c15313 100644
--- a/lib/Controller/ApiController.php
+++ b/lib/Controller/ApiController.php
@@ -91,8 +91,8 @@ public function deleteUser(string $userId): DataResponse {
#[PublicPage]
public function getJwks(): JSONResponse {
try {
- $jwk = $this->jwkService->getJwk();
- return new JSONResponse(['keys' => [$jwk]]);
+ $jwks = $this->jwkService->getJwks();
+ return new JSONResponse(['keys' => $jwks]);
// return new JSONResponse($this->jwkService->debug());
} catch (\Exception|\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
diff --git a/lib/Service/JwkService.php b/lib/Service/JwkService.php
index 97d22d14..dbfede28 100644
--- a/lib/Service/JwkService.php
+++ b/lib/Service/JwkService.php
@@ -19,9 +19,13 @@
class JwkService {
- public const PEM_PRIVATE_KEY_SETTINGS_KEY = 'pemPrivateKey';
- public const PEM_PRIVATE_KEY_EXPIRES_AT_SETTINGS_KEY = 'pemPrivateKeyExpiresAt';
- public const PEM_PRIVATE_KEY_EXPIRES_IN_SECONDS = 60 * 2;
+ public const PEM_SIG_KEY_SETTINGS_KEY = 'pemSignatureKey';
+ public const PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY = 'pemSignatureKeyExpiresAt';
+ public const PEM_SIG_KEY_EXPIRES_IN_SECONDS = 60 * 2;
+
+ public const PEM_ENC_KEY_SETTINGS_KEY = 'pemEncryptionKey';
+ public const PEM_ENC_KEY_EXPIRES_AT_SETTINGS_KEY = 'pemEncryptionKeyExpiresAt';
+ public const PEM_ENC_KEY_EXPIRES_IN_SECONDS = 60 * 2;
public function __construct(
private IAppConfig $appConfig,
@@ -29,23 +33,43 @@ public function __construct(
}
/**
- * Get our stored private PEM key (or regenerate it if it's expired)
+ * Get our stored signature PEM key (or regenerate it if it's expired)
+ *
+ * @param bool $refresh
+ * @return string
+ * @throws AppConfigTypeConflictException
+ */
+ public function getMyPemSignatureKey(bool $refresh = true): string {
+ $pemSignatureKey = $this->appConfig->getAppValueString(self::PEM_SIG_KEY_SETTINGS_KEY, lazy: true);
+ $pemSignatureKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
+
+ if ($pemSignatureKey === '' || $pemSignatureKeyExpiresAt === 0 || ($refresh && time() > $pemSignatureKeyExpiresAt)) {
+ $pemSignatureKey = $this->generatePemPrivateKey();
+ // store the key
+ $this->appConfig->setAppValueString(self::PEM_SIG_KEY_SETTINGS_KEY, $pemSignatureKey, lazy: true);
+ $this->appConfig->setAppValueInt(self::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, time() + self::PEM_SIG_KEY_EXPIRES_IN_SECONDS, lazy: true);
+ }
+ return $pemSignatureKey;
+ }
+
+ /**
+ * Get our stored encryption PEM key (or regenerate it if it's expired)
*
* @param bool $refresh
* @return string
* @throws AppConfigTypeConflictException
*/
- public function getMyPemPrivateKey(bool $refresh = true): string {
- $pemPrivateKey = $this->appConfig->getAppValueString(self::PEM_PRIVATE_KEY_SETTINGS_KEY, lazy: true);
- $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_PRIVATE_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
+ public function getMyEncryptionKey(bool $refresh = true): string {
+ $pemEncryptionKey = $this->appConfig->getAppValueString(self::PEM_ENC_KEY_SETTINGS_KEY, lazy: true);
+ $pemEncryptionKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_ENC_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
- if ($pemPrivateKey === '' || $pemPrivateKeyExpiresAt === 0 || ($refresh && time() > $pemPrivateKeyExpiresAt)) {
- $pemPrivateKey = $this->generatePemPrivateKey();
+ if ($pemEncryptionKey === '' || $pemEncryptionKeyExpiresAt === 0 || ($refresh && time() > $pemEncryptionKeyExpiresAt)) {
+ $pemEncryptionKey = $this->generatePemPrivateKey();
// store the key
- $this->appConfig->setAppValueString(self::PEM_PRIVATE_KEY_SETTINGS_KEY, $pemPrivateKey, lazy: true);
- $this->appConfig->setAppValueInt(self::PEM_PRIVATE_KEY_EXPIRES_AT_SETTINGS_KEY, time() + self::PEM_PRIVATE_KEY_EXPIRES_IN_SECONDS, lazy: true);
+ $this->appConfig->setAppValueString(self::PEM_ENC_KEY_SETTINGS_KEY, $pemEncryptionKey, lazy: true);
+ $this->appConfig->setAppValueInt(self::PEM_ENC_KEY_EXPIRES_AT_SETTINGS_KEY, time() + self::PEM_ENC_KEY_EXPIRES_IN_SECONDS, lazy: true);
}
- return $pemPrivateKey;
+ return $pemEncryptionKey;
}
/**
@@ -55,7 +79,7 @@ public function getMyPemPrivateKey(bool $refresh = true): string {
*/
public function generatePemPrivateKey(): string {
$config = [
- // 'digest_alg' => 'sha512',
+ // 'digest_alg' => 'ES384',
// 'private_key_bits' => 4096,
'private_key_type' => OPENSSL_KEYTYPE_EC,
// 'private_key_type' => OPENSSL_KEYTYPE_RSA,
@@ -79,25 +103,36 @@ public function generatePemPrivateKey(): string {
* @return array
* @throws AppConfigTypeConflictException
*/
- public function getJwk(): array {
- $myPemPrivateKey = $this->getMyPemPrivateKey();
- $sslPrivateKey = openssl_pkey_get_private($myPemPrivateKey);
- $sslPublicKey = openssl_pkey_get_details($sslPrivateKey);
- return $this->getSigJwkFromSslKey($sslPublicKey);
+ public function getJwks(): array {
+ $myPemSignatureKey = $this->getMyPemSignatureKey(false);
+ $sslSignatureKey = openssl_pkey_get_private($myPemSignatureKey);
+ $sslSignatureKeyDetails = openssl_pkey_get_details($sslSignatureKey);
+
+ $myPemEncryptionKey = $this->getMyEncryptionKey(true);
+ $sslEncryptionKey = openssl_pkey_get_private($myPemEncryptionKey);
+ $sslEncryptionKeyDetails = openssl_pkey_get_details($sslEncryptionKey);
+ return [
+ $this->getJwkFromSslKey($sslSignatureKeyDetails),
+ $this->getJwkFromSslKey($sslEncryptionKeyDetails, isEncryptionKey: true),
+ ];
// $pubKeyPem = $sslPublicKey['key'];
// return $this->getJwkFromPem($pubKeyPem)->jsonSerialize();
}
- public function getSigJwkFromSslKey(array $sslKey): array {
- $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_PRIVATE_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
- return [
+ public function getJwkFromSslKey(array $sslKeyDetails, bool $isEncryptionKey = false): array {
+ $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
+ $jwk = [
'kty' => 'EC',
- 'use' => 'sig',
- 'kid' => 'sig_key_' . $pemPrivateKeyExpiresAt,
+ 'use' => $isEncryptionKey ? 'enc' : 'sig',
+ 'kid' => ($isEncryptionKey ? 'enc' : 'sig') . '_key_' . $pemPrivateKeyExpiresAt,
'crv' => 'P-384',
- 'x' => \rtrim(\strtr(\base64_encode($sslKey['ec']['x']), '+/', '-_'), '='),
- 'y' => \rtrim(\strtr(\base64_encode($sslKey['ec']['y']), '+/', '-_'), '='),
+ 'x' => \rtrim(\strtr(\base64_encode($sslKeyDetails['ec']['x']), '+/', '-_'), '='),
+ 'y' => \rtrim(\strtr(\base64_encode($sslKeyDetails['ec']['y']), '+/', '-_'), '='),
];
+ if ($isEncryptionKey) {
+ $jwk['alg'] = 'ECDH-ES+A192KW';
+ }
+ return $jwk;
}
/**
@@ -108,7 +143,7 @@ public function getSigJwkFromSslKey(array $sslKey): array {
* @throws AppConfigTypeConflictException
*/
public function getJwkFromPem(string $pemKey): KeyInterface {
- $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_PRIVATE_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
+ $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
$options = [
'use' => 'sig',
'alg' => 'RS512',
@@ -132,40 +167,45 @@ public function createJwt(array $payload, \OpenSSLAsymmetricKey $key, string $ke
}
public function debug(): array {
- $myPemPrivateKey = $this->getMyPemPrivateKey();
+ $myPemPrivateKey = $this->getMyPemSignatureKey();
$sslPrivateKey = openssl_pkey_get_private($myPemPrivateKey);
$pubKey = openssl_pkey_get_details($sslPrivateKey);
$pubKeyPem = $pubKey['key'];
$payload = ['lll' => 'aaa'];
- $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_PRIVATE_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
- $signedJwtToken = $this->createJwt($payload, $sslPrivateKey, 'sig_key_' . $pemPrivateKeyExpiresAt, 'RS512');
+ $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
+ $signedJwtToken = $this->createJwt($payload, $sslPrivateKey, 'sig_key_' . $pemPrivateKeyExpiresAt, 'ES384');
// check content of JWT
- $rawJwks = ['keys' => [$this->getSigJwkFromSslKey($pubKey)]];
- $jwks = JWK::parseKeySet($rawJwks, 'RS512');
+ $rawJwks = ['keys' => [$this->getJwkFromSslKey($pubKey)]];
+ $jwks = JWK::parseKeySet($rawJwks, 'ES384');
$jwtPayload = JWT::decode($signedJwtToken, $jwks);
$jwtPayloadArray = json_decode(json_encode($jwtPayload), true);
+ // check header of JWT
+ $jwtParts = explode('.', $signedJwtToken, 3);
+ $jwtHeader = json_decode(JWT::urlsafeB64Decode($jwtParts[0]), true);
+
return [
- 'public_jwk' => $this->getSigJwkFromSslKey($pubKey),
+ 'public_jwk' => $this->getJwkFromSslKey($pubKey),
'public_pem' => $pubKeyPem,
'private_pem' => $myPemPrivateKey,
- 'payload' => $payload,
+ 'initial_payload' => $payload,
'signed_jwt' => $signedJwtToken,
- 'jwt_payload' => $jwtPayloadArray,
+ 'jwt_header' => $jwtHeader,
+ 'decoded_jwt_payload' => $jwtPayloadArray,
'arrays_are_equal' => $payload === $jwtPayloadArray,
];
}
public function debugRSA(): array {
- $myPemPrivateKey = $this->getMyPemPrivateKey();
+ $myPemPrivateKey = $this->getMyPemSignatureKey();
$sslPrivateKey = openssl_pkey_get_private($myPemPrivateKey);
$pubKey = openssl_pkey_get_details($sslPrivateKey);
$pubKeyPem = $pubKey['key'];
$payload = ['lll' => 'aaa'];
- $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_PRIVATE_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
+ $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
$signedJwtToken = $this->createJwt($payload, $sslPrivateKey, 'sig_key_' . $pemPrivateKeyExpiresAt);
// check content of JWT
From 7d1a1ec12abe2b7fa933fd5da162097d8d3dfe23 Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Wed, 29 Oct 2025 12:45:24 +0100
Subject: [PATCH 05/25] add lint-eslint action
Signed-off-by: Julien Veyssier
---
.github/workflows/lint-eslint.yml | 100 ++++++++++++++++++++++++++++++
1 file changed, 100 insertions(+)
create mode 100644 .github/workflows/lint-eslint.yml
diff --git a/.github/workflows/lint-eslint.yml b/.github/workflows/lint-eslint.yml
new file mode 100644
index 00000000..0a535f77
--- /dev/null
+++ b/.github/workflows/lint-eslint.yml
@@ -0,0 +1,100 @@
+# This workflow is provided via the organization template repository
+#
+# https://github.com/nextcloud/.github
+# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
+#
+# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
+# SPDX-License-Identifier: MIT
+
+name: Lint eslint
+
+on: pull_request
+
+permissions:
+ contents: read
+
+concurrency:
+ group: lint-eslint-${{ github.head_ref || github.run_id }}
+ cancel-in-progress: true
+
+jobs:
+ changes:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+
+ outputs:
+ src: ${{ steps.changes.outputs.src}}
+
+ steps:
+ - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
+ id: changes
+ continue-on-error: true
+ with:
+ filters: |
+ src:
+ - '.github/workflows/**'
+ - 'src/**'
+ - 'appinfo/info.xml'
+ - 'package.json'
+ - 'package-lock.json'
+ - 'tsconfig.json'
+ - '.eslintrc.*'
+ - '.eslintignore'
+ - '**.js'
+ - '**.ts'
+ - '**.vue'
+
+ lint:
+ runs-on: ubuntu-latest
+
+ needs: changes
+ if: needs.changes.outputs.src != 'false'
+
+ name: NPM lint
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ with:
+ persist-credentials: false
+
+ - name: Read package.json node and npm engines version
+ uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
+ id: versions
+ with:
+ fallbackNode: '^20'
+ fallbackNpm: '^10'
+
+ - name: Set up node ${{ steps.versions.outputs.nodeVersion }}
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+ with:
+ node-version: ${{ steps.versions.outputs.nodeVersion }}
+
+ - name: Set up npm ${{ steps.versions.outputs.npmVersion }}
+ run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
+
+ - name: Install dependencies
+ env:
+ CYPRESS_INSTALL_BINARY: 0
+ PUPPETEER_SKIP_DOWNLOAD: true
+ run: npm ci
+
+ - name: Lint
+ run: npm run lint
+
+ summary:
+ permissions:
+ contents: none
+ runs-on: ubuntu-latest
+ needs: [changes, lint]
+
+ if: always()
+
+ # This is the summary, we just avoid to rename it so that branch protection rules still match
+ name: eslint
+
+ steps:
+ - name: Summary status
+ run: if ${{ needs.changes.outputs.src != 'false' && needs.lint.result != 'success' }}; then exit 1; fi
From 0590bb1a1f6084a134423d4a3b5e88943552dec4 Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Wed, 29 Oct 2025 12:59:41 +0100
Subject: [PATCH 06/25] add new boolean setting to enable private key jwt auth
Signed-off-by: Julien Veyssier
---
lib/Command/UpsertProvider.php | 4 ++++
lib/Service/JwkService.php | 4 +---
lib/Service/ProviderService.php | 3 +++
src/components/AdminSettings.vue | 1 +
src/components/SettingsForm.vue | 20 ++++++++++++++++++--
tests/unit/Service/ProviderServiceTest.php | 4 ++++
6 files changed, 31 insertions(+), 5 deletions(-)
diff --git a/lib/Command/UpsertProvider.php b/lib/Command/UpsertProvider.php
index 83e4b817..be9238aa 100644
--- a/lib/Command/UpsertProvider.php
+++ b/lib/Command/UpsertProvider.php
@@ -27,6 +27,10 @@ class UpsertProvider extends Base {
'shortcut' => null, 'mode' => InputOption::VALUE_REQUIRED, 'setting_key' => ProviderService::SETTING_UNIQUE_UID,
'description' => 'Determines if unique user ids shall be used or not. 1 to enable, 0 to disable',
],
+ 'use-private-key-jwt' => [
+ 'shortcut' => null, 'mode' => InputOption::VALUE_REQUIRED, 'setting_key' => ProviderService::SETTING_USE_PRIVATE_KEY_JWT,
+ 'description' => 'If enabled, user_oidc will use the private key JWT authentication method instead of using the client secret. 1 to enable, 0 to disable (default when creating a new provider)',
+ ],
'check-bearer' => [
'shortcut' => null, 'mode' => InputOption::VALUE_REQUIRED, 'setting_key' => ProviderService::SETTING_CHECK_BEARER,
'description' => 'Determines if Nextcloud API/WebDav calls should check the Bearer token against this provider or not. 1 to enable, 0 to disable (default when creating a new provider)',
diff --git a/lib/Service/JwkService.php b/lib/Service/JwkService.php
index dbfede28..69567446 100644
--- a/lib/Service/JwkService.php
+++ b/lib/Service/JwkService.php
@@ -128,10 +128,8 @@ public function getJwkFromSslKey(array $sslKeyDetails, bool $isEncryptionKey = f
'crv' => 'P-384',
'x' => \rtrim(\strtr(\base64_encode($sslKeyDetails['ec']['x']), '+/', '-_'), '='),
'y' => \rtrim(\strtr(\base64_encode($sslKeyDetails['ec']['y']), '+/', '-_'), '='),
+ 'alg' => $isEncryptionKey ? 'ECDH-ES+A192KW' : 'ES384',
];
- if ($isEncryptionKey) {
- $jwk['alg'] = 'ECDH-ES+A192KW';
- }
return $jwk;
}
diff --git a/lib/Service/ProviderService.php b/lib/Service/ProviderService.php
index f5a82a51..30087274 100644
--- a/lib/Service/ProviderService.php
+++ b/lib/Service/ProviderService.php
@@ -23,6 +23,7 @@
*/
class ProviderService {
public const SETTING_CHECK_BEARER = 'checkBearer';
+ public const SETTING_USE_PRIVATE_KEY_JWT = 'usePrivateKeyJwt';
public const SETTING_SEND_ID_TOKEN_HINT = 'sendIdTokenHint';
public const SETTING_BEARER_PROVISIONING = 'bearerProvisioning';
public const SETTING_UNIQUE_UID = 'uniqueUid';
@@ -68,6 +69,7 @@ class ProviderService {
self::SETTING_BEARER_PROVISIONING => false,
self::SETTING_UNIQUE_UID => true,
self::SETTING_CHECK_BEARER => false,
+ self::SETTING_USE_PRIVATE_KEY_JWT => false,
self::SETTING_SEND_ID_TOKEN_HINT => false,
self::SETTING_RESTRICT_LOGIN_TO_GROUPS => false,
self::SETTING_AZURE_GROUP_NAMES => false,
@@ -186,6 +188,7 @@ public function getSupportedSettings(): array {
self::SETTING_MAPPING_BIRTHDATE,
self::SETTING_UNIQUE_UID,
self::SETTING_CHECK_BEARER,
+ self::SETTING_USE_PRIVATE_KEY_JWT,
self::SETTING_SEND_ID_TOKEN_HINT,
self::SETTING_BEARER_PROVISIONING,
self::SETTING_EXTRA_CLAIMS,
diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue
index ea630bfb..06c1fe93 100644
--- a/src/components/AdminSettings.vue
+++ b/src/components/AdminSettings.vue
@@ -197,6 +197,7 @@ export default {
endSessionEndpoint: '',
postLogoutUri: '',
settings: {
+ usePrivateKeyJwt: false,
uniqueUid: true,
checkBearer: false,
bearerProvisioning: false,
diff --git a/src/components/SettingsForm.vue b/src/components/SettingsForm.vue
index 8abdab66..fda6d04d 100644
--- a/src/components/SettingsForm.vue
+++ b/src/components/SettingsForm.vue
@@ -23,13 +23,21 @@
type="text"
required>
+
+ {{ t('user_oidc', 'Use private key JWT authentication method') }}
+
+
+
@@ -397,6 +405,14 @@ export default {
identifierLength() {
return this.localProvider.identifier.length
},
+ clientSecretPlaceholder() {
+ if (this.localProvider.settings.usePrivateKeyJwt) {
+ return t('user_oidc', 'Not used with private key JWT authentication')
+ }
+ return this.update
+ ? t('user_oidc', 'Leave empty to keep existing')
+ : null
+ },
},
created() {
this.localProvider = this.provider
diff --git a/tests/unit/Service/ProviderServiceTest.php b/tests/unit/Service/ProviderServiceTest.php
index 24fd9ffb..cdf58683 100644
--- a/tests/unit/Service/ProviderServiceTest.php
+++ b/tests/unit/Service/ProviderServiceTest.php
@@ -90,6 +90,7 @@ public function testGetProvidersWithSettings() {
'mappingBirthdate' => '1',
'uniqueUid' => true,
'checkBearer' => true,
+ 'usePrivateKeyJwt' => true,
'bearerProvisioning' => true,
'sendIdTokenHint' => true,
'extraClaims' => '1',
@@ -137,6 +138,7 @@ public function testGetProvidersWithSettings() {
'mappingBirthdate' => '1',
'uniqueUid' => true,
'checkBearer' => true,
+ 'usePrivateKeyJwt' => true,
'bearerProvisioning' => true,
'sendIdTokenHint' => true,
'extraClaims' => '1',
@@ -160,6 +162,7 @@ public function testSetSettings() {
'mappingGroups' => 'groups',
'uniqueUid' => true,
'checkBearer' => false,
+ 'usePrivateKeyJwt' => false,
'bearerProvisioning' => false,
'sendIdTokenHint' => true,
'extraClaims' => 'claim1 claim2',
@@ -220,6 +223,7 @@ public function testSetSettings() {
[Application::APP_ID, 'provider-1-' . ProviderService::SETTING_MAPPING_BIRTHDATE, '', true, 'birthdate'],
[Application::APP_ID, 'provider-1-' . ProviderService::SETTING_UNIQUE_UID, '', true, '1'],
[Application::APP_ID, 'provider-1-' . ProviderService::SETTING_CHECK_BEARER, '', true, '0'],
+ [Application::APP_ID, 'provider-1-' . ProviderService::SETTING_USE_PRIVATE_KEY_JWT, '', true, '0'],
[Application::APP_ID, 'provider-1-' . ProviderService::SETTING_BEARER_PROVISIONING, '', true, '0'],
[Application::APP_ID, 'provider-1-' . ProviderService::SETTING_SEND_ID_TOKEN_HINT, '', true, '1'],
[Application::APP_ID, 'provider-1-' . ProviderService::SETTING_EXTRA_CLAIMS, '', true, 'claim1 claim2'],
From 9ae4166dcf8475676d7708496017120c4848b1db Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Wed, 29 Oct 2025 16:48:49 +0100
Subject: [PATCH 07/25] implement private jwt login flow by passing the client
assertion to the token endpoint
Signed-off-by: Julien Veyssier
---
lib/Controller/LoginController.php | 13 +++++++++++--
lib/Service/JwkService.php | 22 ++++++++++++++++++++++
2 files changed, 33 insertions(+), 2 deletions(-)
diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php
index 9835620d..f5fc9869 100644
--- a/lib/Controller/LoginController.php
+++ b/lib/Controller/LoginController.php
@@ -21,6 +21,7 @@
use OCA\UserOIDC\Event\TokenObtainedEvent;
use OCA\UserOIDC\Helper\HttpClientHelper;
use OCA\UserOIDC\Service\DiscoveryService;
+use OCA\UserOIDC\Service\JwkService;
use OCA\UserOIDC\Service\LdapService;
use OCA\UserOIDC\Service\OIDCService;
use OCA\UserOIDC\Service\ProviderService;
@@ -101,6 +102,7 @@ public function __construct(
private TokenService $tokenService,
private OidcService $oidcService,
private ServerVersion $serverVersion,
+ private JwkService $jwkService,
) {
parent::__construct($request, $config, $l10n);
}
@@ -402,6 +404,7 @@ public function code(string $state = '', string $code = '', string $scope = '',
$oidcSystemConfig = $this->config->getSystemValue('user_oidc', []);
$isPkceSupported = in_array('S256', $discovery['code_challenge_methods_supported'] ?? [], true);
$isPkceEnabled = $isPkceSupported && ($oidcSystemConfig['use_pkce'] ?? true);
+ $usePrivateKeyJwt = $this->providerService->getSetting($providerId, ProviderService::SETTING_USE_PRIVATE_KEY_JWT, '0') !== '0';
try {
$requestBody = [
@@ -431,7 +434,8 @@ public function code(string $state = '', string $code = '', string $scope = '',
$tokenEndpointAuthMethod = 'client_secret_post';
}
- if ($tokenEndpointAuthMethod === 'client_secret_basic') {
+ // private key JWT auth does not work with client_secret_basic, we don't wanna pass the client secret
+ if ($tokenEndpointAuthMethod === 'client_secret_basic' && !$usePrivateKeyJwt) {
$headers = [
'Authorization' => 'Basic ' . base64_encode($provider->getClientId() . ':' . $providerClientSecret),
'Content-Type' => 'application/x-www-form-urlencoded',
@@ -439,7 +443,12 @@ public function code(string $state = '', string $code = '', string $scope = '',
} else {
// Assuming client_secret_post as no other option is supported currently
$requestBody['client_id'] = $provider->getClientId();
- $requestBody['client_secret'] = $providerClientSecret;
+ if ($usePrivateKeyJwt) {
+ $requestBody['client_assertion_type'] = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
+ $requestBody['client_assertion'] = $this->jwkService->generateClientAssertion($provider, $discovery['issuer'], $code);
+ } else {
+ $requestBody['client_secret'] = $providerClientSecret;
+ }
}
$body = $this->clientService->post(
diff --git a/lib/Service/JwkService.php b/lib/Service/JwkService.php
index 69567446..33c8d258 100644
--- a/lib/Service/JwkService.php
+++ b/lib/Service/JwkService.php
@@ -10,6 +10,7 @@
require_once __DIR__ . '/../../vendor/autoload.php';
+use OCA\UserOIDC\Db\Provider;
use OCA\UserOIDC\Vendor\Firebase\JWT\JWK;
use OCA\UserOIDC\Vendor\Firebase\JWT\JWT;
use OCP\AppFramework\Services\IAppConfig;
@@ -164,6 +165,27 @@ public function createJwt(array $payload, \OpenSSLAsymmetricKey $key, string $ke
return JWT::encode($payload, $key, $alg, $keyId);
}
+ public function generateClientAssertion(Provider $provider, string $discoveryIssuer, ?string $code = null): string {
+ $myPemPrivateKey = $this->getMyPemSignatureKey();
+ $sslPrivateKey = openssl_pkey_get_private($myPemPrivateKey);
+ $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
+
+ $payload = [
+ 'sub' => $provider->getClientId(),
+ 'aud' => $discoveryIssuer,
+ 'iss' => $provider->getClientId(),
+ 'iat' => time(),
+ 'exp' => time() + 60,
+ 'jti' => \bin2hex(\random_bytes(16)),
+ ];
+
+ if ($code !== null) {
+ $payload['code'] = $code;
+ }
+
+ return $this->createJwt($payload, $sslPrivateKey, 'sig_key_' . $pemPrivateKeyExpiresAt, 'ES384');
+ }
+
public function debug(): array {
$myPemPrivateKey = $this->getMyPemSignatureKey();
$sslPrivateKey = openssl_pkey_get_private($myPemPrivateKey);
From f0ff9f2e4d19994e3228003d3c9ef13eecb62a6b Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Wed, 29 Oct 2025 17:25:40 +0100
Subject: [PATCH 08/25] adjust README and settings UI
Signed-off-by: Julien Veyssier
---
README.md | 16 +++++++++++++++
src/components/SettingsForm.vue | 36 ++++++++++++++++++++++++++-------
2 files changed, 45 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index fc315ea9..6590cc04 100644
--- a/README.md
+++ b/README.md
@@ -218,6 +218,22 @@ parameter to the login URL.
sudo -u www-data php var/www/nextcloud/occ config:app:set --type=string --value=0 user_oidc allow_multiple_user_backends
```
+### Private key JWT authentication
+
+This app supports private key JWT authentication.
+See the `private_key_jwt` authentication method in https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication
+This can be enabled for each provider individually in their settings
+(in Nextcloud's admin settings or with the `occ user_oidc:provider` command`).
+
+If you enable that for a provider, you must configure the client accordingly on the IdP side.
+In the IdP client settings, you should be able to make it accept a signed JWT and set the JWKS URL.
+
+The JWKS URL you should set in your IdP's client settings is `https:///index.php/apps/user_oidc/jwks`.
+The exact URL is displayed in the user_oidc admin settings.
+
+In Keycloak, you can set the JWKS URL in the "Keys" tab of the client settings. Then you can choose "Signed Jwt"
+as the "Client Authenticator" in the "Credentials" tab.
+
### PKCE
This app supports PKCE (Proof Key for Code Exchange).
diff --git a/src/components/SettingsForm.vue b/src/components/SettingsForm.vue
index fda6d04d..121f7d1c 100644
--- a/src/components/SettingsForm.vue
+++ b/src/components/SettingsForm.vue
@@ -23,13 +23,24 @@
type="text"
required>
-
- {{ t('user_oidc', 'Use private key JWT authentication method') }}
-
-
-
+
+
+ {{ t('user_oidc', 'Use private key JWT authentication method') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('user_oidc', 'Use this JWKS URL in your IdP\'s client settings: {jwksUrl}', { jwksUrl }) }}
+
Date: Mon, 3 Nov 2025 15:42:15 +0100
Subject: [PATCH 09/25] increase key lifetime to one hour, add comments on
when/why we refresh
Signed-off-by: Julien Veyssier
---
lib/Service/JwkService.php | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/lib/Service/JwkService.php b/lib/Service/JwkService.php
index 33c8d258..53aebeaf 100644
--- a/lib/Service/JwkService.php
+++ b/lib/Service/JwkService.php
@@ -22,11 +22,11 @@ class JwkService {
public const PEM_SIG_KEY_SETTINGS_KEY = 'pemSignatureKey';
public const PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY = 'pemSignatureKeyExpiresAt';
- public const PEM_SIG_KEY_EXPIRES_IN_SECONDS = 60 * 2;
+ public const PEM_SIG_KEY_EXPIRES_IN_SECONDS = 60 * 60;
public const PEM_ENC_KEY_SETTINGS_KEY = 'pemEncryptionKey';
public const PEM_ENC_KEY_EXPIRES_AT_SETTINGS_KEY = 'pemEncryptionKeyExpiresAt';
- public const PEM_ENC_KEY_EXPIRES_IN_SECONDS = 60 * 2;
+ public const PEM_ENC_KEY_EXPIRES_IN_SECONDS = 60 * 60;
public function __construct(
private IAppConfig $appConfig,
@@ -105,6 +105,7 @@ public function generatePemPrivateKey(): string {
* @throws AppConfigTypeConflictException
*/
public function getJwks(): array {
+ // we don't refresh here to make sure the IdP will get the key that was used to sign the client assertion
$myPemSignatureKey = $this->getMyPemSignatureKey(false);
$sslSignatureKey = openssl_pkey_get_private($myPemSignatureKey);
$sslSignatureKeyDetails = openssl_pkey_get_details($sslSignatureKey);
@@ -166,6 +167,7 @@ public function createJwt(array $payload, \OpenSSLAsymmetricKey $key, string $ke
}
public function generateClientAssertion(Provider $provider, string $discoveryIssuer, ?string $code = null): string {
+ // we refresh (if needed) here to make sure we use a key that will be served to the IdP in a few seconds
$myPemPrivateKey = $this->getMyPemSignatureKey();
$sslPrivateKey = openssl_pkey_get_private($myPemPrivateKey);
$pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
From 94920d53b344c89448a9fecb61deedd53cb174a6 Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Tue, 4 Nov 2025 17:16:58 +0100
Subject: [PATCH 10/25] implement small signature key tests
Signed-off-by: Julien Veyssier
---
lib/Controller/ApiController.php | 4 +-
tests/unit/Service/JwkServiceTest.php | 68 +++++++++++++++++++++++++++
2 files changed, 70 insertions(+), 2 deletions(-)
create mode 100644 tests/unit/Service/JwkServiceTest.php
diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php
index c4c15313..fe0e62a9 100644
--- a/lib/Controller/ApiController.php
+++ b/lib/Controller/ApiController.php
@@ -92,8 +92,8 @@ public function deleteUser(string $userId): DataResponse {
public function getJwks(): JSONResponse {
try {
$jwks = $this->jwkService->getJwks();
- return new JSONResponse(['keys' => $jwks]);
- // return new JSONResponse($this->jwkService->debug());
+// return new JSONResponse(['keys' => $jwks]);
+ return new JSONResponse($this->jwkService->debug());
} catch (\Exception|\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
}
diff --git a/tests/unit/Service/JwkServiceTest.php b/tests/unit/Service/JwkServiceTest.php
new file mode 100644
index 00000000..4f600200
--- /dev/null
+++ b/tests/unit/Service/JwkServiceTest.php
@@ -0,0 +1,68 @@
+appConfig = $this->createMock(IAppConfig::class);
+ $this->jwkService = new JwkService($this->appConfig);
+ }
+
+ public function testSignatureKeyAndJwt() {
+ $myPemPrivateKey = $this->jwkService->getMyPemSignatureKey();
+ $sslPrivateKey = openssl_pkey_get_private($myPemPrivateKey);
+ $pubKey = openssl_pkey_get_details($sslPrivateKey);
+ $pubKeyPem = $pubKey['key'];
+ $this->assertStringContainsString('-----BEGIN PUBLIC KEY-----', $pubKeyPem);
+ $this->assertStringContainsString('-----END PUBLIC KEY-----', $pubKeyPem);
+ $this->assertStringContainsString('-----BEGIN PRIVATE KEY-----', $myPemPrivateKey);
+ $this->assertStringContainsString('-----END PRIVATE KEY-----', $myPemPrivateKey);
+
+ $initialPayload = ['nice' => 'example'];
+ $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(JwkService::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
+ $jwkId = 'sig_key_' . $pemPrivateKeyExpiresAt;
+ $signedJwtToken = $this->jwkService->createJwt($initialPayload, $sslPrivateKey, $jwkId, 'ES384');
+
+ // check JWK
+ $jwk = $this->jwkService->getJwkFromSslKey($pubKey);
+ $this->assertEquals('EC', $jwk['kty']);
+ $this->assertEquals('sig', $jwk['use']);
+ $this->assertEquals($jwkId, $jwk['kid']);
+ $this->assertEquals('P-384', $jwk['crv']);
+ $this->assertEquals('ES384', $jwk['alg']);
+
+ // check content of JWT
+ $rawJwks = ['keys' => [$jwk]];
+ $jwks = JWK::parseKeySet($rawJwks, 'ES384');
+ $jwtPayload = JWT::decode($signedJwtToken, $jwks);
+ $jwtPayloadArray = json_decode(json_encode($jwtPayload), true);
+ $this->assertEquals($initialPayload, $jwtPayloadArray);
+
+ // check header of JWT
+ $jwtParts = explode('.', $signedJwtToken, 3);
+ $jwtHeader = json_decode(JWT::urlsafeB64Decode($jwtParts[0]), true);
+ $this->assertEquals('JWT', $jwtHeader['typ']);
+ $this->assertEquals('ES384', $jwtHeader['alg']);
+ $this->assertEquals($jwkId, $jwtHeader['kid']);
+ }
+}
From a272ab54d51369ab54c32067a5659f720a2ee719 Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Tue, 4 Nov 2025 17:35:26 +0100
Subject: [PATCH 11/25] implement small encryption key tests
Signed-off-by: Julien Veyssier
---
lib/Controller/ApiController.php | 4 ++--
lib/Service/JwkService.php | 14 +++++++++-----
tests/unit/Service/JwkServiceTest.php | 26 +++++++++++++++++++++-----
3 files changed, 32 insertions(+), 12 deletions(-)
diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php
index fe0e62a9..c4c15313 100644
--- a/lib/Controller/ApiController.php
+++ b/lib/Controller/ApiController.php
@@ -92,8 +92,8 @@ public function deleteUser(string $userId): DataResponse {
public function getJwks(): JSONResponse {
try {
$jwks = $this->jwkService->getJwks();
-// return new JSONResponse(['keys' => $jwks]);
- return new JSONResponse($this->jwkService->debug());
+ return new JSONResponse(['keys' => $jwks]);
+ // return new JSONResponse($this->jwkService->debug());
} catch (\Exception|\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
}
diff --git a/lib/Service/JwkService.php b/lib/Service/JwkService.php
index 53aebeaf..47e1be65 100644
--- a/lib/Service/JwkService.php
+++ b/lib/Service/JwkService.php
@@ -23,10 +23,14 @@ class JwkService {
public const PEM_SIG_KEY_SETTINGS_KEY = 'pemSignatureKey';
public const PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY = 'pemSignatureKeyExpiresAt';
public const PEM_SIG_KEY_EXPIRES_IN_SECONDS = 60 * 60;
+ public const PEM_SIG_KEY_ALGORITHM = 'ES384';
+ public const PEM_SIG_KEY_CURVE = 'P-384';
public const PEM_ENC_KEY_SETTINGS_KEY = 'pemEncryptionKey';
public const PEM_ENC_KEY_EXPIRES_AT_SETTINGS_KEY = 'pemEncryptionKeyExpiresAt';
public const PEM_ENC_KEY_EXPIRES_IN_SECONDS = 60 * 60;
+ public const PEM_ENC_KEY_ALGORITHM = 'ECDH-ES+A192KW';
+ public const PEM_ENC_KEY_CURVE = 'P-384';
public function __construct(
private IAppConfig $appConfig,
@@ -127,10 +131,10 @@ public function getJwkFromSslKey(array $sslKeyDetails, bool $isEncryptionKey = f
'kty' => 'EC',
'use' => $isEncryptionKey ? 'enc' : 'sig',
'kid' => ($isEncryptionKey ? 'enc' : 'sig') . '_key_' . $pemPrivateKeyExpiresAt,
- 'crv' => 'P-384',
+ 'crv' => $isEncryptionKey ? self::PEM_ENC_KEY_CURVE : self::PEM_SIG_KEY_CURVE,
'x' => \rtrim(\strtr(\base64_encode($sslKeyDetails['ec']['x']), '+/', '-_'), '='),
'y' => \rtrim(\strtr(\base64_encode($sslKeyDetails['ec']['y']), '+/', '-_'), '='),
- 'alg' => $isEncryptionKey ? 'ECDH-ES+A192KW' : 'ES384',
+ 'alg' => $isEncryptionKey ? self::PEM_ENC_KEY_ALGORITHM : self::PEM_SIG_KEY_ALGORITHM,
];
return $jwk;
}
@@ -185,7 +189,7 @@ public function generateClientAssertion(Provider $provider, string $discoveryIss
$payload['code'] = $code;
}
- return $this->createJwt($payload, $sslPrivateKey, 'sig_key_' . $pemPrivateKeyExpiresAt, 'ES384');
+ return $this->createJwt($payload, $sslPrivateKey, 'sig_key_' . $pemPrivateKeyExpiresAt, self::PEM_SIG_KEY_ALGORITHM);
}
public function debug(): array {
@@ -196,11 +200,11 @@ public function debug(): array {
$payload = ['lll' => 'aaa'];
$pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
- $signedJwtToken = $this->createJwt($payload, $sslPrivateKey, 'sig_key_' . $pemPrivateKeyExpiresAt, 'ES384');
+ $signedJwtToken = $this->createJwt($payload, $sslPrivateKey, 'sig_key_' . $pemPrivateKeyExpiresAt, self::PEM_SIG_KEY_ALGORITHM);
// check content of JWT
$rawJwks = ['keys' => [$this->getJwkFromSslKey($pubKey)]];
- $jwks = JWK::parseKeySet($rawJwks, 'ES384');
+ $jwks = JWK::parseKeySet($rawJwks, self::PEM_SIG_KEY_ALGORITHM);
$jwtPayload = JWT::decode($signedJwtToken, $jwks);
$jwtPayloadArray = json_decode(json_encode($jwtPayload), true);
diff --git a/tests/unit/Service/JwkServiceTest.php b/tests/unit/Service/JwkServiceTest.php
index 4f600200..98b7350f 100644
--- a/tests/unit/Service/JwkServiceTest.php
+++ b/tests/unit/Service/JwkServiceTest.php
@@ -41,19 +41,19 @@ public function testSignatureKeyAndJwt() {
$initialPayload = ['nice' => 'example'];
$pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(JwkService::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
$jwkId = 'sig_key_' . $pemPrivateKeyExpiresAt;
- $signedJwtToken = $this->jwkService->createJwt($initialPayload, $sslPrivateKey, $jwkId, 'ES384');
+ $signedJwtToken = $this->jwkService->createJwt($initialPayload, $sslPrivateKey, $jwkId, JwkService::PEM_SIG_KEY_ALGORITHM);
// check JWK
$jwk = $this->jwkService->getJwkFromSslKey($pubKey);
$this->assertEquals('EC', $jwk['kty']);
$this->assertEquals('sig', $jwk['use']);
$this->assertEquals($jwkId, $jwk['kid']);
- $this->assertEquals('P-384', $jwk['crv']);
- $this->assertEquals('ES384', $jwk['alg']);
+ $this->assertEquals(JwkService::PEM_SIG_KEY_CURVE, $jwk['crv']);
+ $this->assertEquals(JwkService::PEM_SIG_KEY_ALGORITHM, $jwk['alg']);
// check content of JWT
$rawJwks = ['keys' => [$jwk]];
- $jwks = JWK::parseKeySet($rawJwks, 'ES384');
+ $jwks = JWK::parseKeySet($rawJwks, JwkService::PEM_SIG_KEY_ALGORITHM);
$jwtPayload = JWT::decode($signedJwtToken, $jwks);
$jwtPayloadArray = json_decode(json_encode($jwtPayload), true);
$this->assertEquals($initialPayload, $jwtPayloadArray);
@@ -62,7 +62,23 @@ public function testSignatureKeyAndJwt() {
$jwtParts = explode('.', $signedJwtToken, 3);
$jwtHeader = json_decode(JWT::urlsafeB64Decode($jwtParts[0]), true);
$this->assertEquals('JWT', $jwtHeader['typ']);
- $this->assertEquals('ES384', $jwtHeader['alg']);
+ $this->assertEquals(JwkService::PEM_SIG_KEY_ALGORITHM, $jwtHeader['alg']);
$this->assertEquals($jwkId, $jwtHeader['kid']);
}
+
+ public function testEncryptionKey() {
+ $myPemEncryptionKey = $this->jwkService->getMyEncryptionKey();
+ $sslEncryptionKey = openssl_pkey_get_private($myPemEncryptionKey);
+ $sslEncryptionKeyDetails = openssl_pkey_get_details($sslEncryptionKey);
+ $encJwk = $this->jwkService->getJwkFromSslKey($sslEncryptionKeyDetails, isEncryptionKey: true);
+
+ $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(JwkService::PEM_ENC_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
+ $encJwkId = 'enc_key_' . $pemPrivateKeyExpiresAt;
+
+ $this->assertEquals('EC', $encJwk['kty']);
+ $this->assertEquals('enc', $encJwk['use']);
+ $this->assertEquals($encJwkId, $encJwk['kid']);
+ $this->assertEquals(JwkService::PEM_ENC_KEY_CURVE, $encJwk['crv']);
+ $this->assertEquals(JwkService::PEM_ENC_KEY_ALGORITHM, $encJwk['alg']);
+ }
}
From b2b1dbb60db15de9577f1cf485fd5a77db74e4da Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Thu, 6 Nov 2025 16:10:30 +0100
Subject: [PATCH 12/25] implement JWE encryption + decryption with algos
working with singpass
Signed-off-by: Julien Veyssier
---
appinfo/routes.php | 2 +
composer.json | 3 +-
composer.lock | 422 +++++++++++++++++++++++++------
lib/Controller/ApiController.php | 23 +-
lib/Service/JweService.php | 181 +++++++++++++
lib/Service/JwkService.php | 56 +---
6 files changed, 563 insertions(+), 124 deletions(-)
create mode 100644 lib/Service/JweService.php
diff --git a/appinfo/routes.php b/appinfo/routes.php
index 05584c8e..732b58a1 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -20,6 +20,8 @@
['name' => 'api#createUser', 'url' => '/user', 'verb' => 'POST'],
['name' => 'api#deleteUser', 'url' => '/user/{userId}', 'verb' => 'DELETE'],
['name' => 'api#getJwks', 'url' => '/jwks', 'verb' => 'GET'],
+ ['name' => 'api#debugJwk', 'url' => '/debug-jwk', 'verb' => 'GET'],
+ ['name' => 'api#debugJwe', 'url' => '/debug-jwe', 'verb' => 'GET'],
['name' => 'id4me#showLogin', 'url' => '/id4me', 'verb' => 'GET'],
['name' => 'id4me#login', 'url' => '/id4me', 'verb' => 'POST'],
diff --git a/composer.json b/composer.json
index 287c3905..4bf8ad1a 100644
--- a/composer.json
+++ b/composer.json
@@ -33,7 +33,8 @@
"id4me/id4me-rp": "^1.2",
"firebase/php-jwt": "^7",
"bamarni/composer-bin-plugin": "^1.4",
- "strobotti/php-jwk": "^1.3"
+ "web-token/jwt-library": "^4.1",
+ "spomky-labs/aes-key-wrap": "^7.0"
},
"require-dev": {
"nextcloud/coding-standard": "^1.0.0",
diff --git a/composer.lock b/composer.lock
index a1dc04f1..caa890e6 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "2c4f937df9960ebd989efe94d706e93b",
+ "content-hash": "326ae587745b1643b94c392a3e82acf3",
"packages": [
{
"name": "bamarni/composer-bin-plugin",
@@ -63,6 +63,66 @@
},
"time": "2026-02-04T10:18:12+00:00"
},
+ {
+ "name": "brick/math",
+ "version": "0.14.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/brick/math.git",
+ "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629",
+ "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.2"
+ },
+ "require-dev": {
+ "php-coveralls/php-coveralls": "^2.2",
+ "phpstan/phpstan": "2.1.22",
+ "phpunit/phpunit": "^11.5"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Brick\\Math\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Arbitrary-precision arithmetic library",
+ "keywords": [
+ "Arbitrary-precision",
+ "BigInteger",
+ "BigRational",
+ "arithmetic",
+ "bigdecimal",
+ "bignum",
+ "bignumber",
+ "brick",
+ "decimal",
+ "integer",
+ "math",
+ "mathematics",
+ "rational"
+ ],
+ "support": {
+ "issues": "https://github.com/brick/math/issues",
+ "source": "https://github.com/brick/math/tree/0.14.8"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/BenMorel",
+ "type": "github"
+ }
+ ],
+ "time": "2026-02-10T14:33:43+00:00"
+ },
{
"name": "firebase/php-jwt",
"version": "v7.0.3",
@@ -282,33 +342,88 @@
"time": "2026-03-19T02:54:44+00:00"
},
{
- "name": "strobotti/php-jwk",
- "version": "v1.3.0",
+ "name": "psr/clock",
+ "version": "1.0.0",
"source": {
"type": "git",
- "url": "https://github.com/Strobotti/php-jwk.git",
- "reference": "a78580b55380f25bd8110452a5a031e36043551e"
+ "url": "https://github.com/php-fig/clock.git",
+ "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Strobotti/php-jwk/zipball/a78580b55380f25bd8110452a5a031e36043551e",
- "reference": "a78580b55380f25bd8110452a5a031e36043551e",
+ "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d",
+ "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d",
"shasum": ""
},
"require": {
- "ext-json": "*",
+ "php": "^7.0 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Psr\\Clock\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for reading the clock.",
+ "homepage": "https://github.com/php-fig/clock",
+ "keywords": [
+ "clock",
+ "now",
+ "psr",
+ "psr-20",
+ "time"
+ ],
+ "support": {
+ "issues": "https://github.com/php-fig/clock/issues",
+ "source": "https://github.com/php-fig/clock/tree/1.0.0"
+ },
+ "time": "2022-11-25T14:36:26+00:00"
+ },
+ {
+ "name": "spomky-labs/aes-key-wrap",
+ "version": "v7.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Spomky-Labs/aes-key-wrap.git",
+ "reference": "fbeb834b1f83aa8fbdfbd4c12124f71d4c1606ae"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Spomky-Labs/aes-key-wrap/zipball/fbeb834b1f83aa8fbdfbd4c12124f71d4c1606ae",
+ "reference": "fbeb834b1f83aa8fbdfbd4c12124f71d4c1606ae",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
"ext-openssl": "*",
- "php": ">=7.2.0",
- "phpseclib/phpseclib": "^2.0"
+ "php": ">=8.0"
},
"require-dev": {
- "friendsofphp/php-cs-fixer": "^2.16",
- "phpunit/phpunit": "^8.0"
+ "infection/infection": "^0.25.4",
+ "phpstan/extension-installer": "^1.1",
+ "phpstan/phpstan": "^1.0",
+ "phpstan/phpstan-beberlei-assert": "^1.0",
+ "phpstan/phpstan-deprecation-rules": "^1.0",
+ "phpstan/phpstan-phpunit": "^1.0",
+ "phpstan/phpstan-strict-rules": "^1.0",
+ "phpunit/phpunit": "^9.0",
+ "rector/rector": "^0.12.5",
+ "symplify/easy-coding-standard": "^10.0"
},
"type": "library",
"autoload": {
"psr-4": {
- "Strobotti\\JWK\\": "src/"
+ "AESKW\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -317,23 +432,236 @@
],
"authors": [
{
- "name": "Juha Jantunen",
- "email": "juha@strobotti.com",
- "homepage": "https://www.strobotti.com",
- "role": "Developer"
+ "name": "Florent Morselli",
+ "homepage": "https://github.com/Spomky-Labs/aes-key-wrap/contributors"
}
],
- "description": "A small PHP library to handle JWKs (Json Web Keys)",
- "homepage": "https://github.com/Strobotti/php-jwk",
+ "description": "AES Key Wrap for PHP.",
+ "homepage": "https://github.com/Spomky-Labs/aes-key-wrap",
"keywords": [
+ "A128KW",
+ "A192KW",
+ "A256KW",
+ "RFC3394",
+ "RFC5649",
+ "aes",
+ "key",
+ "padding",
+ "wrap"
+ ],
+ "support": {
+ "issues": "https://github.com/Spomky-Labs/aes-key-wrap/issues",
+ "source": "https://github.com/Spomky-Labs/aes-key-wrap/tree/v7.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Spomky",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/FlorentMorselli",
+ "type": "patreon"
+ }
+ ],
+ "time": "2021-12-08T20:36:59+00:00"
+ },
+ {
+ "name": "spomky-labs/pki-framework",
+ "version": "1.4.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Spomky-Labs/pki-framework.git",
+ "reference": "f0e9a548df4e3942886adc9b7830581a46334631"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/f0e9a548df4e3942886adc9b7830581a46334631",
+ "reference": "f0e9a548df4e3942886adc9b7830581a46334631",
+ "shasum": ""
+ },
+ "require": {
+ "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14",
+ "ext-mbstring": "*",
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0",
+ "ext-gmp": "*",
+ "ext-openssl": "*",
+ "infection/infection": "^0.28|^0.29|^0.31",
+ "php-parallel-lint/php-parallel-lint": "^1.3",
+ "phpstan/extension-installer": "^1.3|^2.0",
+ "phpstan/phpstan": "^1.8|^2.0",
+ "phpstan/phpstan-deprecation-rules": "^1.0|^2.0",
+ "phpstan/phpstan-phpunit": "^1.1|^2.0",
+ "phpstan/phpstan-strict-rules": "^1.3|^2.0",
+ "phpunit/phpunit": "^10.1|^11.0|^12.0",
+ "rector/rector": "^1.0|^2.0",
+ "roave/security-advisories": "dev-latest",
+ "symfony/string": "^6.4|^7.0|^8.0",
+ "symfony/var-dumper": "^6.4|^7.0|^8.0",
+ "symplify/easy-coding-standard": "^12.0"
+ },
+ "suggest": {
+ "ext-bcmath": "For better performance (or GMP)",
+ "ext-gmp": "For better performance (or BCMath)",
+ "ext-openssl": "For OpenSSL based cyphering"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "SpomkyLabs\\Pki\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Joni Eskelinen",
+ "email": "jonieske@gmail.com",
+ "role": "Original developer"
+ },
+ {
+ "name": "Florent Morselli",
+ "email": "florent.morselli@spomky-labs.com",
+ "role": "Spomky-Labs PKI Framework developer"
+ }
+ ],
+ "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.",
+ "homepage": "https://github.com/spomky-labs/pki-framework",
+ "keywords": [
+ "DER",
+ "Private Key",
+ "ac",
+ "algorithm identifier",
+ "asn.1",
+ "asn1",
+ "attribute certificate",
+ "certificate",
+ "certification request",
+ "cryptography",
+ "csr",
+ "decrypt",
+ "ec",
+ "encrypt",
+ "pem",
+ "pkcs",
+ "public key",
+ "rsa",
+ "sign",
+ "signature",
+ "verify",
+ "x.509",
+ "x.690",
+ "x509",
+ "x690"
+ ],
+ "support": {
+ "issues": "https://github.com/Spomky-Labs/pki-framework/issues",
+ "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Spomky",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/FlorentMorselli",
+ "type": "patreon"
+ }
+ ],
+ "time": "2025-12-20T12:57:40+00:00"
+ },
+ {
+ "name": "web-token/jwt-library",
+ "version": "4.1.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/web-token/jwt-library.git",
+ "reference": "690d4dd47b78f423cb90457f858e4106e1deb728"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/web-token/jwt-library/zipball/690d4dd47b78f423cb90457f858e4106e1deb728",
+ "reference": "690d4dd47b78f423cb90457f858e4106e1deb728",
+ "shasum": ""
+ },
+ "require": {
+ "brick/math": "^0.12|^0.13|^0.14",
+ "php": ">=8.2",
+ "psr/clock": "^1.0",
+ "spomky-labs/pki-framework": "^1.2.1"
+ },
+ "conflict": {
+ "spomky-labs/jose": "*"
+ },
+ "suggest": {
+ "ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance",
+ "ext-gmp": "GMP or BCMath is highly recommended to improve the library performance",
+ "ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)",
+ "ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys",
+ "paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys",
+ "spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)",
+ "symfony/console": "Needed to use console commands",
+ "symfony/http-client": "To enable JKU/X5U support."
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Jose\\Component\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Florent Morselli",
+ "homepage": "https://github.com/Spomky"
+ },
+ {
+ "name": "All contributors",
+ "homepage": "https://github.com/web-token/jwt-framework/contributors"
+ }
+ ],
+ "description": "JWT library",
+ "homepage": "https://github.com/web-token",
+ "keywords": [
+ "JOSE",
+ "JWE",
"JWK",
- "JWKS"
+ "JWKSet",
+ "JWS",
+ "Jot",
+ "RFC7515",
+ "RFC7516",
+ "RFC7517",
+ "RFC7518",
+ "RFC7519",
+ "RFC7520",
+ "bundle",
+ "jwa",
+ "jwt",
+ "symfony"
],
"support": {
- "issues": "https://github.com/Strobotti/php-jwk/issues",
- "source": "https://github.com/Strobotti/php-jwk/tree/master"
+ "issues": "https://github.com/web-token/jwt-library/issues",
+ "source": "https://github.com/web-token/jwt-library/tree/4.1.3"
},
- "time": "2020-04-01T03:22:04+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/Spomky",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/FlorentMorselli",
+ "type": "patreon"
+ }
+ ],
+ "time": "2025-12-18T14:27:35+00:00"
}
],
"packages-dev": [
@@ -1390,54 +1718,6 @@
],
"time": "2026-02-18T12:37:06+00:00"
},
- {
- "name": "psr/clock",
- "version": "1.0.0",
- "source": {
- "type": "git",
- "url": "https://github.com/php-fig/clock.git",
- "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d",
- "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d",
- "shasum": ""
- },
- "require": {
- "php": "^7.0 || ^8.0"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Psr\\Clock\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "PHP-FIG",
- "homepage": "https://www.php-fig.org/"
- }
- ],
- "description": "Common interface for reading the clock.",
- "homepage": "https://github.com/php-fig/clock",
- "keywords": [
- "clock",
- "now",
- "psr",
- "psr-20",
- "time"
- ],
- "support": {
- "issues": "https://github.com/php-fig/clock/issues",
- "source": "https://github.com/php-fig/clock/tree/1.0.0"
- },
- "time": "2022-11-25T14:36:26+00:00"
- },
{
"name": "psr/container",
"version": "2.0.2",
diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php
index c4c15313..f51e653c 100644
--- a/lib/Controller/ApiController.php
+++ b/lib/Controller/ApiController.php
@@ -11,6 +11,7 @@
use OCA\UserOIDC\AppInfo\Application;
use OCA\UserOIDC\Db\UserMapper;
+use OCA\UserOIDC\Service\JweService;
use OCA\UserOIDC\Service\JwkService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
@@ -33,6 +34,7 @@ public function __construct(
private UserMapper $userMapper,
private IUserManager $userManager,
private JwkService $jwkService,
+ private JweService $jweService,
) {
parent::__construct(Application::APP_ID, $request);
}
@@ -93,7 +95,26 @@ public function getJwks(): JSONResponse {
try {
$jwks = $this->jwkService->getJwks();
return new JSONResponse(['keys' => $jwks]);
- // return new JSONResponse($this->jwkService->debug());
+ } catch (\Exception|\Throwable $e) {
+ return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ }
+
+ #[NoCSRFRequired]
+ #[PublicPage]
+ public function debugJwk(): JSONResponse {
+ try {
+ return new JSONResponse($this->jwkService->debug());
+ } catch (\Exception|\Throwable $e) {
+ return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ }
+
+ #[NoCSRFRequired]
+ #[PublicPage]
+ public function debugJwe(): JSONResponse {
+ try {
+ return new JSONResponse($this->jweService->debug());
} catch (\Exception|\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
}
diff --git a/lib/Service/JweService.php b/lib/Service/JweService.php
new file mode 100644
index 00000000..bca4a5b9
--- /dev/null
+++ b/lib/Service/JweService.php
@@ -0,0 +1,181 @@
+ JWE
+
+ $algorithmManager = new AlgorithmManager([
+ new A256KW(),
+ new A256CBCHS512(),
+ new ECDHESA192KW(),
+ new A192CBCHS384(),
+ ]);
+
+ // The compression method manager with the DEF (Deflate) method.
+ //$compressionMethodManager = new CompressionMethodManager([
+ // new Deflate(),
+ //]);
+
+ // We instantiate our JWE Builder.
+ $jweBuilder = new JWEBuilder(
+ $algorithmManager,
+ );
+
+ // Our key.
+ $jwk = new JWK($encryptionJwk);
+
+ // The payload we want to encrypt. It MUST be a string.
+ $payload = json_encode($payloadArray);
+
+ $jwe = $jweBuilder
+ ->create() // We want to create a new JWE
+ ->withPayload($payload) // We set the payload
+ ->withSharedProtectedHeader([
+ // Key Encryption Algorithm
+ // 'alg' => 'A256KW',
+ 'alg' => 'ECDH-ES+A192KW',
+ // Content Encryption Algorithm
+ // 'enc' => 'A256CBC-HS512',
+ 'enc' => 'A192CBC-HS384',
+ //'zip' => 'DEF' // Not recommended.
+ ])
+ ->addRecipient($jwk) // We add a recipient (a shared key or public key).
+ ->build();
+
+ $serializer = new CompactSerializer(); // The serializer
+ return $serializer->serialize($jwe, 0); // We serialize the recipient at index 0 (we only have one recipient).
+ }
+
+ public function decryptSerializedJwe(string $serializedJwe, array $jwkArray): string {
+ $algorithmManager = new AlgorithmManager([
+ new A256KW(),
+ new A256CBCHS512(),
+ new ECDHESA192KW(),
+ new A192CBCHS384(),
+ ]);
+
+ // The compression method manager with the DEF (Deflate) method.
+ //$compressionMethodManager = new CompressionMethodManager([
+ // new Deflate(),
+ //]);
+
+ // We instantiate our JWE Decrypter.
+ $jweDecrypter = new JWEDecrypter(
+ $algorithmManager,
+ );
+
+ // Our key.
+ $jwk = new JWK($jwkArray);
+
+ // The serializer manager. We only use the JWE Compact Serialization Mode.
+ $serializerManager = new JWESerializerManager([
+ new CompactSerializer(),
+ ]);
+
+ // ----------- OPTION 1
+ /*
+ // We try to load the token.
+ $jwe = $serializerManager->unserialize($serializedJwe);
+
+ // We decrypt the token. This method does NOT check the header.
+ $success = $jweDecrypter->decryptUsingKey($jwe, $jwk, 0);
+ */
+
+ // ----------- OPTION 2
+ $headerCheckerManager = new HeaderCheckerManager(
+ // Provide the allowed algorithms using the previously created
+ // AlgorithmManager.
+ [
+ new AlgorithmChecker(
+ // $keyEncryptionAlgorithmManager->list()
+ $algorithmManager->list()
+ )
+ ],
+ // Provide the appropriate TokenTypeSupport[].
+ [
+ new JWETokenSupport(),
+ ]
+ );
+
+ // no idea why TooManyArguments is thrown by psalm
+ /** @psalm-suppress TooManyArguments */
+ $jweLoader = new JWELoader(
+ $serializerManager,
+ $jweDecrypter,
+ $headerCheckerManager,
+ );
+
+ $jwe = $jweLoader->loadAndDecryptWithKey($serializedJwe, $jwk, $recipient);
+ $payload = $jwe->getPayload();
+ if ($payload === null) {
+ throw new \Exception('Could not decrypt JWE, payload is null');
+ }
+
+ return $payload;
+ }
+
+ public function debug(): array {
+ // get encryption key, both formats
+ $myPemEncryptionKey = $this->jwkService->getMyEncryptionKey(true);
+ $sslEncryptionKey = openssl_pkey_get_private($myPemEncryptionKey);
+ $sslEncryptionKeyDetails = openssl_pkey_get_details($sslEncryptionKey);
+ $encPublicJwk = $this->jwkService->getJwkFromSslKey($sslEncryptionKeyDetails, isEncryptionKey: true);
+ $encPrivJwk = $this->jwkService->getJwkFromSslKey($sslEncryptionKeyDetails, isEncryptionKey: true, includePrivateKey: true);
+
+ $payloadArray = [
+ 'iat' => time(),
+ 'nbf' => time(),
+ 'exp' => time() + 3600,
+ 'iss' => 'My service',
+ 'aud' => 'Your application',
+ ];
+
+ /*
+ $exampleJwkArray = [
+ 'kty' => 'oct',
+ 'k' => 'dzI6nbW4OcNF-AtfxGAmuyz7IpHRudBI0WgGjZWgaRJt6prBn3DARXgUR8NVwKhfL43QBIU2Un3AvCGCHRgY4TbEqhOi8-i98xxmCggNjde4oaW6wkJ2NgM3Ss9SOX9zS3lcVzdCMdum-RwVJ301kbin4UtGztuzJBeg5oVN00MGxjC2xWwyI0tgXVs-zJs5WlafCuGfX1HrVkIf5bvpE0MQCSjdJpSeVao6-RSTYDajZf7T88a2eVjeW31mMAg-jzAWfUrii61T_bYPJFOXW8kkRWoa1InLRdG6bKB9wQs9-VdXZP60Q4Yuj_WZ-lO7qV9AEFrUkkjpaDgZT86w2g',
+ ];
+ $serializedJweToken = $this->createSerializedJwe($payloadArray, $exampleJwkArray);
+ $decryptedJweString = $this->decryptSerializedJwe($serializedJweToken, $exampleJwkArray);
+ */
+ $serializedJweToken = $this->createSerializedJwe($payloadArray, $encPublicJwk);
+ $decryptedJweString = $this->decryptSerializedJwe($serializedJweToken, $encPrivJwk);
+
+ return [
+ 'input_payloadArray' => $payloadArray,
+ 'input_serializedJweToken' => $serializedJweToken,
+ 'output_payloadArray' => json_decode($decryptedJweString, true),
+ ];
+ }
+}
diff --git a/lib/Service/JwkService.php b/lib/Service/JwkService.php
index 47e1be65..3ec3c199 100644
--- a/lib/Service/JwkService.php
+++ b/lib/Service/JwkService.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
/**
- * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
@@ -15,8 +15,6 @@
use OCA\UserOIDC\Vendor\Firebase\JWT\JWT;
use OCP\AppFramework\Services\IAppConfig;
use OCP\Exceptions\AppConfigTypeConflictException;
-use Strobotti\JWK\Key\KeyInterface;
-use Strobotti\JWK\KeyFactory;
class JwkService {
@@ -121,11 +119,9 @@ public function getJwks(): array {
$this->getJwkFromSslKey($sslSignatureKeyDetails),
$this->getJwkFromSslKey($sslEncryptionKeyDetails, isEncryptionKey: true),
];
- // $pubKeyPem = $sslPublicKey['key'];
- // return $this->getJwkFromPem($pubKeyPem)->jsonSerialize();
}
- public function getJwkFromSslKey(array $sslKeyDetails, bool $isEncryptionKey = false): array {
+ public function getJwkFromSslKey(array $sslKeyDetails, bool $isEncryptionKey = false, bool $includePrivateKey = false): array {
$pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
$jwk = [
'kty' => 'EC',
@@ -136,27 +132,12 @@ public function getJwkFromSslKey(array $sslKeyDetails, bool $isEncryptionKey = f
'y' => \rtrim(\strtr(\base64_encode($sslKeyDetails['ec']['y']), '+/', '-_'), '='),
'alg' => $isEncryptionKey ? self::PEM_ENC_KEY_ALGORITHM : self::PEM_SIG_KEY_ALGORITHM,
];
+ if ($includePrivateKey) {
+ $jwk['d'] = \rtrim(\strtr(\base64_encode($sslKeyDetails['ec']['d']), '+/', '-_'), '=');
+ }
return $jwk;
}
- /**
- * Build a JWK from a PEM (public) key
- *
- * @param string $pemKey
- * @return KeyInterface
- * @throws AppConfigTypeConflictException
- */
- public function getJwkFromPem(string $pemKey): KeyInterface {
- $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
- $options = [
- 'use' => 'sig',
- 'alg' => 'RS512',
- 'kid' => 'sig_key_' . $pemPrivateKeyExpiresAt,
- ];
- $keyFactory = new KeyFactory();
- return $keyFactory->createFromPem($pemKey, $options);
- }
-
/**
* Create a JWT token signed with a given private SSL key
*
@@ -223,31 +204,4 @@ public function debug(): array {
'arrays_are_equal' => $payload === $jwtPayloadArray,
];
}
-
- public function debugRSA(): array {
- $myPemPrivateKey = $this->getMyPemSignatureKey();
- $sslPrivateKey = openssl_pkey_get_private($myPemPrivateKey);
- $pubKey = openssl_pkey_get_details($sslPrivateKey);
- $pubKeyPem = $pubKey['key'];
-
- $payload = ['lll' => 'aaa'];
- $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
- $signedJwtToken = $this->createJwt($payload, $sslPrivateKey, 'sig_key_' . $pemPrivateKeyExpiresAt);
-
- // check content of JWT
- $rawJwks = ['keys' => [$this->getJwkFromPem($pubKeyPem)->jsonSerialize()]];
- $jwks = JWK::parseKeySet($rawJwks, 'RS512');
- $jwtPayload = JWT::decode($signedJwtToken, $jwks);
- $jwtPayloadArray = json_decode(json_encode($jwtPayload), true);
-
- return [
- 'public_jwk' => $this->getJwkFromPem($pubKeyPem)->jsonSerialize(),
- 'public_pem' => $pubKeyPem,
- 'private_pem' => $myPemPrivateKey,
- 'payload' => $payload,
- 'signed_jwt' => $signedJwtToken,
- 'jwt_payload' => $jwtPayloadArray,
- 'arrays_are_equal' => $payload === $jwtPayloadArray,
- ];
- }
}
From dab8d055738f7ae2d7bb52293d7f81660dc4b63c Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Thu, 6 Nov 2025 16:18:27 +0100
Subject: [PATCH 13/25] implement JWE tests
Signed-off-by: Julien Veyssier
---
lib/Service/JweService.php | 1 -
tests/unit/Service/JweServiceTest.php | 54 +++++++++++++++++++++++++++
2 files changed, 54 insertions(+), 1 deletion(-)
create mode 100644 tests/unit/Service/JweServiceTest.php
diff --git a/lib/Service/JweService.php b/lib/Service/JweService.php
index bca4a5b9..0301c2ce 100644
--- a/lib/Service/JweService.php
+++ b/lib/Service/JweService.php
@@ -146,7 +146,6 @@ public function decryptSerializedJwe(string $serializedJwe, array $jwkArray): st
}
public function debug(): array {
- // get encryption key, both formats
$myPemEncryptionKey = $this->jwkService->getMyEncryptionKey(true);
$sslEncryptionKey = openssl_pkey_get_private($myPemEncryptionKey);
$sslEncryptionKeyDetails = openssl_pkey_get_details($sslEncryptionKey);
diff --git a/tests/unit/Service/JweServiceTest.php b/tests/unit/Service/JweServiceTest.php
new file mode 100644
index 00000000..d2d31546
--- /dev/null
+++ b/tests/unit/Service/JweServiceTest.php
@@ -0,0 +1,54 @@
+appConfig = $this->createMock(IAppConfig::class);
+ $this->jwkService = new JwkService($this->appConfig);
+ $this->jweService = new JweService($this->jwkService);
+ }
+
+ public function testJweEncryptionDecryption() {
+ $myPemEncryptionKey = $this->jwkService->getMyEncryptionKey(true);
+ $sslEncryptionKey = openssl_pkey_get_private($myPemEncryptionKey);
+ $sslEncryptionKeyDetails = openssl_pkey_get_details($sslEncryptionKey);
+ $encPublicJwk = $this->jwkService->getJwkFromSslKey($sslEncryptionKeyDetails, isEncryptionKey: true);
+ $encPrivJwk = $this->jwkService->getJwkFromSslKey($sslEncryptionKeyDetails, isEncryptionKey: true, includePrivateKey: true);
+
+ $inputPayloadArray = [
+ 'iat' => time(),
+ 'nbf' => time(),
+ 'exp' => time() + 3600,
+ 'iss' => 'My service',
+ 'aud' => 'Your application',
+ ];
+
+ $serializedJweToken = $this->jweService->createSerializedJwe($inputPayloadArray, $encPublicJwk);
+ $decryptedJweString = $this->jweService->decryptSerializedJwe($serializedJweToken, $encPrivJwk);
+
+ $outputPayloadArray = json_decode($decryptedJweString, true);
+ $this->assertEquals($inputPayloadArray, $outputPayloadArray);
+ }
+}
From ee26b13d898c66aae2f8ac4f1af3d91b0d6e0378 Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Thu, 6 Nov 2025 18:00:39 +0100
Subject: [PATCH 14/25] polish
Signed-off-by: Julien Veyssier
---
appinfo/info.xml | 1 +
lib/Service/JweService.php | 31 ++++++++++++++++++++++++-------
2 files changed, 25 insertions(+), 7 deletions(-)
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 354651ef..33557c4b 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -23,6 +23,7 @@
https://github.com/nextcloud/user_oidc/issues
https://github.com/nextcloud/user_oidc
+
diff --git a/lib/Service/JweService.php b/lib/Service/JweService.php
index 0301c2ce..35801ae4 100644
--- a/lib/Service/JweService.php
+++ b/lib/Service/JweService.php
@@ -27,14 +27,25 @@
class JweService {
+ public const CONTENT_ENCRYPTION_ALGORITHM = 'A192CBC-HS384';
+
public function __construct(
private JwkService $jwkService,
) {
}
- public function createSerializedJwe(array $payloadArray, array $encryptionJwk): string {
- // encrypt a JWT payload with the enc key => JWE
-
+ /**
+ * @param array $payloadArray the content of the JWE
+ * @param array $encryptionJwk the public key in JWK format
+ * @param string $keyEncryptionAlg the algorithm to use for the key encryption
+ * @param string $contentEncryptionAlg the algorithm to use for the content encryption
+ * @return string
+ */
+ public function createSerializedJwe(
+ array $payloadArray, array $encryptionJwk,
+ string $keyEncryptionAlg = JwkService::PEM_ENC_KEY_ALGORITHM,
+ string $contentEncryptionAlg = self::CONTENT_ENCRYPTION_ALGORITHM,
+ ): string {
$algorithmManager = new AlgorithmManager([
new A256KW(),
new A256CBCHS512(),
@@ -64,10 +75,10 @@ public function createSerializedJwe(array $payloadArray, array $encryptionJwk):
->withSharedProtectedHeader([
// Key Encryption Algorithm
// 'alg' => 'A256KW',
- 'alg' => 'ECDH-ES+A192KW',
+ 'alg' => $keyEncryptionAlg,
// Content Encryption Algorithm
// 'enc' => 'A256CBC-HS512',
- 'enc' => 'A192CBC-HS384',
+ 'enc' => $contentEncryptionAlg,
//'zip' => 'DEF' // Not recommended.
])
->addRecipient($jwk) // We add a recipient (a shared key or public key).
@@ -77,6 +88,12 @@ public function createSerializedJwe(array $payloadArray, array $encryptionJwk):
return $serializer->serialize($jwe, 0); // We serialize the recipient at index 0 (we only have one recipient).
}
+ /**
+ * @param string $serializedJwe the JWE token
+ * @param array $jwkArray the private key in JWK format (with the 'd' attribute)
+ * @return string
+ * @throws \Exception
+ */
public function decryptSerializedJwe(string $serializedJwe, array $jwkArray): string {
$algorithmManager = new AlgorithmManager([
new A256KW(),
@@ -119,8 +136,8 @@ public function decryptSerializedJwe(string $serializedJwe, array $jwkArray): st
[
new AlgorithmChecker(
// $keyEncryptionAlgorithmManager->list()
- $algorithmManager->list()
- )
+ $algorithmManager->list(),
+ ),
],
// Provide the appropriate TokenTypeSupport[].
[
From 04950672253132e2ce62331a5dbbcf604417d954 Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Wed, 19 Nov 2025 12:20:20 +0100
Subject: [PATCH 15/25] tests
Signed-off-by: Julien Veyssier
---
lib/Controller/LoginController.php | 20 +++++++++++++++++
lib/Service/JweService.php | 31 +++++++++++++++++++++++----
tests/unit/Service/JweServiceTest.php | 4 ++--
3 files changed, 49 insertions(+), 6 deletions(-)
diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php
index f5fc9869..441d6ce0 100644
--- a/lib/Controller/LoginController.php
+++ b/lib/Controller/LoginController.php
@@ -21,6 +21,7 @@
use OCA\UserOIDC\Event\TokenObtainedEvent;
use OCA\UserOIDC\Helper\HttpClientHelper;
use OCA\UserOIDC\Service\DiscoveryService;
+use OCA\UserOIDC\Service\JweService;
use OCA\UserOIDC\Service\JwkService;
use OCA\UserOIDC\Service\LdapService;
use OCA\UserOIDC\Service\OIDCService;
@@ -103,6 +104,7 @@ public function __construct(
private OidcService $oidcService,
private ServerVersion $serverVersion,
private JwkService $jwkService,
+ private JweService $jweService,
) {
parent::__construct($request, $config, $l10n);
}
@@ -200,6 +202,8 @@ public function login(int $providerId, ?string $redirectUrl = null) {
$this->session->set(self::NONCE, $nonce);
$oidcSystemConfig = $this->config->getSystemValue('user_oidc', []);
+ // TODO add config param to force PKCE even if not supported in discovery
+ // condition becomes: ($isPkceSupported || $force) && ($oidcSystemConfig['use_pkce'] ?? true)
$isPkceSupported = in_array('S256', $discovery['code_challenge_methods_supported'] ?? [], true);
$isPkceEnabled = $isPkceSupported && ($oidcSystemConfig['use_pkce'] ?? true);
@@ -500,6 +504,22 @@ public function code(string $state = '', string $code = '', string $scope = '',
// TODO: proper error handling
$idTokenRaw = $data['id_token'];
+ if ($usePrivateKeyJwt) {
+ // we could check the header there
+ // if kid is our private JWK, we have a JWE to decrypt
+ // if typ=JWT, we have a classic JWT to decode
+ $jwtParts = explode('.', $idTokenRaw, 3);
+ $jwtHeader = json_decode(JWT::urlsafeB64Decode($jwtParts[0]), true);
+ $this->logger->warning('JWT HEADER', ['jwt_header' => $jwtHeader]);
+ if (isset($jwtHeader['typ']) && $jwtHeader['typ'] === 'JWT') {
+ // we have a JWT
+ } elseif (isset($jwtHeader['cty']) && $jwtHeader['cty'] === 'JWT') {
+ // we have a JWE
+ }
+
+ // $dec = $this->jweService->decryptSerializedJwe($idTokenRaw);
+ // $this->logger->warning('decrypted JWE', ['decrypted_jwe' => json_decode($dec, true)]);
+ }
$jwks = $this->discoveryService->obtainJWK($provider, $idTokenRaw);
JWT::$leeway = 60;
try {
diff --git a/lib/Service/JweService.php b/lib/Service/JweService.php
index 35801ae4..0e22f97b 100644
--- a/lib/Service/JweService.php
+++ b/lib/Service/JweService.php
@@ -41,7 +41,7 @@ public function __construct(
* @param string $contentEncryptionAlg the algorithm to use for the content encryption
* @return string
*/
- public function createSerializedJwe(
+ public function createSerializedJweWithKey(
array $payloadArray, array $encryptionJwk,
string $keyEncryptionAlg = JwkService::PEM_ENC_KEY_ALGORITHM,
string $contentEncryptionAlg = self::CONTENT_ENCRYPTION_ALGORITHM,
@@ -94,7 +94,7 @@ public function createSerializedJwe(
* @return string
* @throws \Exception
*/
- public function decryptSerializedJwe(string $serializedJwe, array $jwkArray): string {
+ public function decryptSerializedJweWithKey(string $serializedJwe, array $jwkArray): string {
$algorithmManager = new AlgorithmManager([
new A256KW(),
new A256CBCHS512(),
@@ -162,6 +162,24 @@ public function decryptSerializedJwe(string $serializedJwe, array $jwkArray): st
return $payload;
}
+ public function decryptSerializedJwe(string $serializedJwe): string {
+ $myPemEncryptionKey = $this->jwkService->getMyEncryptionKey(true);
+ $sslEncryptionKey = openssl_pkey_get_private($myPemEncryptionKey);
+ $sslEncryptionKeyDetails = openssl_pkey_get_details($sslEncryptionKey);
+ $encPrivJwk = $this->jwkService->getJwkFromSslKey($sslEncryptionKeyDetails, isEncryptionKey: true, includePrivateKey: true);
+
+ return $this->decryptSerializedJweWithKey($serializedJwe, $encPrivJwk);
+ }
+
+ public function createSerializedJwe(array $payloadArray): string {
+ $myPemEncryptionKey = $this->jwkService->getMyEncryptionKey(true);
+ $sslEncryptionKey = openssl_pkey_get_private($myPemEncryptionKey);
+ $sslEncryptionKeyDetails = openssl_pkey_get_details($sslEncryptionKey);
+ $encPublicJwk = $this->jwkService->getJwkFromSslKey($sslEncryptionKeyDetails, isEncryptionKey: true);
+
+ return $this->createSerializedJweWithKey($payloadArray, $encPublicJwk);
+ }
+
public function debug(): array {
$myPemEncryptionKey = $this->jwkService->getMyEncryptionKey(true);
$sslEncryptionKey = openssl_pkey_get_private($myPemEncryptionKey);
@@ -185,12 +203,17 @@ public function debug(): array {
$serializedJweToken = $this->createSerializedJwe($payloadArray, $exampleJwkArray);
$decryptedJweString = $this->decryptSerializedJwe($serializedJweToken, $exampleJwkArray);
*/
- $serializedJweToken = $this->createSerializedJwe($payloadArray, $encPublicJwk);
- $decryptedJweString = $this->decryptSerializedJwe($serializedJweToken, $encPrivJwk);
+ $serializedJweToken = $this->createSerializedJweWithKey($payloadArray, $encPublicJwk);
+ $jwtParts = explode('.', $serializedJweToken, 3);
+ $jwtHeader = json_decode(base64_decode($jwtParts[0]), true);
+ $decryptedJweString = $this->decryptSerializedJweWithKey($serializedJweToken, $encPrivJwk);
return [
+ 'public_key' => $encPublicJwk,
+ 'private_key' => $encPrivJwk,
'input_payloadArray' => $payloadArray,
'input_serializedJweToken' => $serializedJweToken,
+ 'jwe_header' => $jwtHeader,
'output_payloadArray' => json_decode($decryptedJweString, true),
];
}
diff --git a/tests/unit/Service/JweServiceTest.php b/tests/unit/Service/JweServiceTest.php
index d2d31546..20473872 100644
--- a/tests/unit/Service/JweServiceTest.php
+++ b/tests/unit/Service/JweServiceTest.php
@@ -45,8 +45,8 @@ public function testJweEncryptionDecryption() {
'aud' => 'Your application',
];
- $serializedJweToken = $this->jweService->createSerializedJwe($inputPayloadArray, $encPublicJwk);
- $decryptedJweString = $this->jweService->decryptSerializedJwe($serializedJweToken, $encPrivJwk);
+ $serializedJweToken = $this->jweService->createSerializedJweWithKey($inputPayloadArray, $encPublicJwk);
+ $decryptedJweString = $this->jweService->decryptSerializedJweWithKey($serializedJweToken, $encPrivJwk);
$outputPayloadArray = json_decode($decryptedJweString, true);
$this->assertEquals($inputPayloadArray, $outputPayloadArray);
From ccce6e1e7c3d6ef6aad07a4124bd30f46595cb26 Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Mon, 24 Nov 2025 15:27:44 +0100
Subject: [PATCH 16/25] last part: detect if a JWT is in a JWE on login
Signed-off-by: Julien Veyssier
---
lib/Controller/LoginController.php | 12 +++++++-----
lib/Service/JweService.php | 21 ++++++++++-----------
tests/unit/Service/JweServiceTest.php | 3 ++-
3 files changed, 19 insertions(+), 17 deletions(-)
diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php
index 441d6ce0..db5affe9 100644
--- a/lib/Controller/LoginController.php
+++ b/lib/Controller/LoginController.php
@@ -512,13 +512,15 @@ public function code(string $state = '', string $code = '', string $scope = '',
$jwtHeader = json_decode(JWT::urlsafeB64Decode($jwtParts[0]), true);
$this->logger->warning('JWT HEADER', ['jwt_header' => $jwtHeader]);
if (isset($jwtHeader['typ']) && $jwtHeader['typ'] === 'JWT') {
- // we have a JWT
+ // we have a JWT, do nothing
} elseif (isset($jwtHeader['cty']) && $jwtHeader['cty'] === 'JWT') {
- // we have a JWE
+ // we have a JWE that contains the JWT string (the ID token)
+ $idTokenRaw = $this->jweService->decryptSerializedJwe($idTokenRaw);
+ $this->logger->warning('raw decrypted JWE', ['decrypted_jwe' => $idTokenRaw]);
+ $this->logger->warning('decrypted+decoded JWE', ['decrypted_jwe' => json_decode($idTokenRaw, true)]);
+ } else {
+ $this->logger->warning('Unsupported id_token when using "private key JWT"', ['id_token' => $idTokenRaw]);
}
-
- // $dec = $this->jweService->decryptSerializedJwe($idTokenRaw);
- // $this->logger->warning('decrypted JWE', ['decrypted_jwe' => json_decode($dec, true)]);
}
$jwks = $this->discoveryService->obtainJWK($provider, $idTokenRaw);
JWT::$leeway = 60;
diff --git a/lib/Service/JweService.php b/lib/Service/JweService.php
index 0e22f97b..5aa2189f 100644
--- a/lib/Service/JweService.php
+++ b/lib/Service/JweService.php
@@ -35,14 +35,14 @@ public function __construct(
}
/**
- * @param array $payloadArray the content of the JWE
+ * @param string $payload the content of the JWE
* @param array $encryptionJwk the public key in JWK format
* @param string $keyEncryptionAlg the algorithm to use for the key encryption
* @param string $contentEncryptionAlg the algorithm to use for the content encryption
* @return string
*/
public function createSerializedJweWithKey(
- array $payloadArray, array $encryptionJwk,
+ string $payload, array $encryptionJwk,
string $keyEncryptionAlg = JwkService::PEM_ENC_KEY_ALGORITHM,
string $contentEncryptionAlg = self::CONTENT_ENCRYPTION_ALGORITHM,
): string {
@@ -66,11 +66,8 @@ public function createSerializedJweWithKey(
// Our key.
$jwk = new JWK($encryptionJwk);
- // The payload we want to encrypt. It MUST be a string.
- $payload = json_encode($payloadArray);
-
$jwe = $jweBuilder
- ->create() // We want to create a new JWE
+ ->create() // We want to create a new JWE
->withPayload($payload) // We set the payload
->withSharedProtectedHeader([
// Key Encryption Algorithm
@@ -80,12 +77,14 @@ public function createSerializedJweWithKey(
// 'enc' => 'A256CBC-HS512',
'enc' => $contentEncryptionAlg,
//'zip' => 'DEF' // Not recommended.
+ 'cty' => 'JWT',
])
->addRecipient($jwk) // We add a recipient (a shared key or public key).
->build();
- $serializer = new CompactSerializer(); // The serializer
- return $serializer->serialize($jwe, 0); // We serialize the recipient at index 0 (we only have one recipient).
+ $serializer = new CompactSerializer();
+ // We serialize the recipient at index 0 (we only have one recipient).
+ return $serializer->serialize($jwe, 0);
}
/**
@@ -171,13 +170,13 @@ public function decryptSerializedJwe(string $serializedJwe): string {
return $this->decryptSerializedJweWithKey($serializedJwe, $encPrivJwk);
}
- public function createSerializedJwe(array $payloadArray): string {
+ public function createSerializedJwe(string $payload): string {
$myPemEncryptionKey = $this->jwkService->getMyEncryptionKey(true);
$sslEncryptionKey = openssl_pkey_get_private($myPemEncryptionKey);
$sslEncryptionKeyDetails = openssl_pkey_get_details($sslEncryptionKey);
$encPublicJwk = $this->jwkService->getJwkFromSslKey($sslEncryptionKeyDetails, isEncryptionKey: true);
- return $this->createSerializedJweWithKey($payloadArray, $encPublicJwk);
+ return $this->createSerializedJweWithKey($payload, $encPublicJwk);
}
public function debug(): array {
@@ -203,7 +202,7 @@ public function debug(): array {
$serializedJweToken = $this->createSerializedJwe($payloadArray, $exampleJwkArray);
$decryptedJweString = $this->decryptSerializedJwe($serializedJweToken, $exampleJwkArray);
*/
- $serializedJweToken = $this->createSerializedJweWithKey($payloadArray, $encPublicJwk);
+ $serializedJweToken = $this->createSerializedJweWithKey(json_encode($payloadArray), $encPublicJwk);
$jwtParts = explode('.', $serializedJweToken, 3);
$jwtHeader = json_decode(base64_decode($jwtParts[0]), true);
$decryptedJweString = $this->decryptSerializedJweWithKey($serializedJweToken, $encPrivJwk);
diff --git a/tests/unit/Service/JweServiceTest.php b/tests/unit/Service/JweServiceTest.php
index 20473872..8efc3c41 100644
--- a/tests/unit/Service/JweServiceTest.php
+++ b/tests/unit/Service/JweServiceTest.php
@@ -44,8 +44,9 @@ public function testJweEncryptionDecryption() {
'iss' => 'My service',
'aud' => 'Your application',
];
+ $inputPayload = json_encode($inputPayloadArray);
- $serializedJweToken = $this->jweService->createSerializedJweWithKey($inputPayloadArray, $encPublicJwk);
+ $serializedJweToken = $this->jweService->createSerializedJweWithKey($inputPayload, $encPublicJwk);
$decryptedJweString = $this->jweService->decryptSerializedJweWithKey($serializedJweToken, $encPrivJwk);
$outputPayloadArray = json_decode($decryptedJweString, true);
From a0348aae18499b68754d9d94ef89d6ad496d0414 Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Wed, 26 Nov 2025 10:30:51 +0100
Subject: [PATCH 17/25] make it possible to force-enable PKCE
Signed-off-by: Julien Veyssier
---
README.md | 9 +++++++++
lib/Controller/LoginController.php | 9 ++++++---
2 files changed, 15 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 6590cc04..dddcc201 100644
--- a/README.md
+++ b/README.md
@@ -247,6 +247,15 @@ You can also manually disable it in `config.php`:
],
```
+You can also force the use of PKCE with:
+``` php
+'user_oidc' => [
+ 'use_pkce' => 'force',
+],
+```
+This will make user_oidc use PKCE even if the `code_challenge_methods_supported` value of the provider's discovery endpoint
+is not defined or does not contain `S256`.
+
### Single logout
Single logout is enabled by default. When logging out of Nextcloud,
diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php
index db5affe9..4cb68b01 100644
--- a/lib/Controller/LoginController.php
+++ b/lib/Controller/LoginController.php
@@ -202,10 +202,11 @@ public function login(int $providerId, ?string $redirectUrl = null) {
$this->session->set(self::NONCE, $nonce);
$oidcSystemConfig = $this->config->getSystemValue('user_oidc', []);
- // TODO add config param to force PKCE even if not supported in discovery
// condition becomes: ($isPkceSupported || $force) && ($oidcSystemConfig['use_pkce'] ?? true)
$isPkceSupported = in_array('S256', $discovery['code_challenge_methods_supported'] ?? [], true);
- $isPkceEnabled = $isPkceSupported && ($oidcSystemConfig['use_pkce'] ?? true);
+ $usePkce = $oidcSystemConfig['use_pkce'] ?? true;
+ $forcePkce = $usePkce === 'force';
+ $isPkceEnabled = $forcePkce || ($isPkceSupported && $usePkce);
if ($isPkceEnabled) {
// PKCE code_challenge see https://datatracker.ietf.org/doc/html/rfc7636
@@ -407,7 +408,9 @@ public function code(string $state = '', string $code = '', string $scope = '',
$oidcSystemConfig = $this->config->getSystemValue('user_oidc', []);
$isPkceSupported = in_array('S256', $discovery['code_challenge_methods_supported'] ?? [], true);
- $isPkceEnabled = $isPkceSupported && ($oidcSystemConfig['use_pkce'] ?? true);
+ $usePkce = $oidcSystemConfig['use_pkce'] ?? true;
+ $forcePkce = $usePkce === 'force';
+ $isPkceEnabled = $forcePkce || ($isPkceSupported && $usePkce);
$usePrivateKeyJwt = $this->providerService->getSetting($providerId, ProviderService::SETTING_USE_PRIVATE_KEY_JWT, '0') !== '0';
try {
From d3f9d9cb842c23e73d4dfd82ae109d8eb61d2852 Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Wed, 26 Nov 2025 10:49:37 +0100
Subject: [PATCH 18/25] add error description in 403 template, use it in code
controller method
Signed-off-by: Julien Veyssier
---
lib/Controller/BaseOidcController.php | 9 ++++++++-
lib/Controller/LoginController.php | 2 +-
templates/error.php | 6 ++++++
3 files changed, 15 insertions(+), 2 deletions(-)
diff --git a/lib/Controller/BaseOidcController.php b/lib/Controller/BaseOidcController.php
index 34c7cf03..654bdbaa 100644
--- a/lib/Controller/BaseOidcController.php
+++ b/lib/Controller/BaseOidcController.php
@@ -55,13 +55,20 @@ protected function buildErrorTemplateResponse(string $message, int $statusCode,
* @param int $statusCode
* @param array $throttleMetadata
* @param bool|null $throttle
+ * @param string|null $errorDescription
* @return TemplateResponse
*/
- protected function build403TemplateResponse(string $message, int $statusCode, array $throttleMetadata = [], ?bool $throttle = null): TemplateResponse {
+ protected function build403TemplateResponse(
+ string $message, int $statusCode, array $throttleMetadata = [], ?bool $throttle = null,
+ ?string $errorDescription = null,
+ ): TemplateResponse {
$params = [
'message' => $message,
'title' => $this->l->t('Access forbidden'),
];
+ if ($errorDescription) {
+ $params['error_description'] = $errorDescription;
+ }
return $this->buildFailureTemplateResponse($params, $statusCode, $throttleMetadata, $throttle);
}
diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php
index 4cb68b01..1403ecb0 100644
--- a/lib/Controller/LoginController.php
+++ b/lib/Controller/LoginController.php
@@ -365,7 +365,7 @@ public function code(string $state = '', string $code = '', string $scope = '',
], Http::STATUS_FORBIDDEN);
}
$message = $this->l10n->t('The identity provider failed to authenticate the user.');
- return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, [], false);
+ return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, [], false, errorDescription: $error_description);
}
$storedState = $this->session->get(self::STATE);
diff --git a/templates/error.php b/templates/error.php
index 5d2ec473..5c4c6a92 100644
--- a/templates/error.php
+++ b/templates/error.php
@@ -10,6 +10,12 @@
+ ';
+ p($_['error_description']);
+ echo '
';
+ } ?>
From 704ea0cf3a0f7a543bc5b2ae5951bb21cf4f5fce Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Thu, 27 Nov 2025 14:37:57 +0100
Subject: [PATCH 19/25] add regexp mechanism to parse user IDs from tokens
Signed-off-by: Julien Veyssier
---
README.md | 22 ++++++++++++++++++++++
lib/Controller/LoginController.php | 2 ++
lib/Service/SettingsService.php | 12 ++++++++++++
lib/User/Backend.php | 4 ++++
4 files changed, 40 insertions(+)
diff --git a/README.md b/README.md
index dddcc201..51759b34 100644
--- a/README.md
+++ b/README.md
@@ -90,6 +90,28 @@ The OpenID Connect backend will ensure that user ids are unique even when multip
id to ensure that a user cannot identify for the same Nextcloud account through different providers.
Therefore, a hash of the provider id and the user id is used. This behaviour can be turned off in the provider options.
+### Parsing user IDs from claims
+
+If your ID tokens do not contain the user ID directly in an attribute/claim,
+you can configure user_oidc to apply a regular expression to extract the user ID from the claim.
+
+```php
+'user_oidc' => [
+ 'user_id_regexp' => 'u=([^,]+)'
+]
+```
+
+This regular expression may or may not contain parenthesis. If it does, only the selected block will be extracted.
+Examples:
+
+* `'[a-zA-Z]+'`
+ * `'123-abc-123'` will give `'abc'`
+ * `'123-abc-345-def'` will give `'abc'`
+* `'u=([^,]+)'`
+ * `'u=123'` will give `'123'`
+ * `'anything,u=123,anything'` will give `'123'`
+ * `'anything,u=123,anything,u=345'` will give `'123'`
+
## Commandline settings
The app could also be configured by commandline.
diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php
index 1403ecb0..1066c773 100644
--- a/lib/Controller/LoginController.php
+++ b/lib/Controller/LoginController.php
@@ -608,6 +608,8 @@ public function code(string $state = '', string $code = '', string $scope = '',
return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, ['reason' => 'failed to provision user']);
}
+ $userId = $this->settingsService->parseUserId($userId);
+
// prevent login of users that are not in a whitelisted group (if activated)
$restrictLoginToGroups = $this->providerService->getSetting($providerId, ProviderService::SETTING_RESTRICT_LOGIN_TO_GROUPS, '0');
if ($restrictLoginToGroups === '1') {
diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php
index ffd75d34..177f7c96 100644
--- a/lib/Service/SettingsService.php
+++ b/lib/Service/SettingsService.php
@@ -13,12 +13,14 @@
use OCA\UserOIDC\AppInfo\Application;
use OCP\Exceptions\AppConfigTypeConflictException;
use OCP\IAppConfig;
+use OCP\IConfig;
use Psr\Log\LoggerInterface;
class SettingsService {
public function __construct(
private IAppConfig $appConfig,
+ private IConfig $config,
private LoggerInterface $logger,
) {
}
@@ -41,4 +43,14 @@ public function setAllowMultipleUserBackEnds(bool $value): void {
$this->appConfig->setValueString(Application::APP_ID, 'allow_multiple_user_backends', $value ? '1' : '0', lazy: true);
}
}
+
+ public function parseUserId(string $userId): string {
+ $oidcSystemConfig = $this->config->getSystemValue('user_oidc', []);
+ if (isset($oidcSystemConfig['user_id_regexp']) && $oidcSystemConfig['user_id_regexp'] !== '') {
+ if (preg_match('/' . $oidcSystemConfig['user_id_regexp'] . '/', $userId, $matches) === 1) {
+ return $matches[1] ?? $matches[0];
+ }
+ }
+ return $userId;
+ }
}
diff --git a/lib/User/Backend.php b/lib/User/Backend.php
index afd90e89..09ebc197 100644
--- a/lib/User/Backend.php
+++ b/lib/User/Backend.php
@@ -18,6 +18,7 @@
use OCA\UserOIDC\Service\LdapService;
use OCA\UserOIDC\Service\ProviderService;
use OCA\UserOIDC\Service\ProvisioningService;
+use OCA\UserOIDC\Service\SettingsService;
use OCA\UserOIDC\User\Validator\IBearerTokenValidator;
use OCA\UserOIDC\User\Validator\SelfEncodedValidator;
use OCA\UserOIDC\User\Validator\UserInfoValidator;
@@ -67,6 +68,7 @@ public function __construct(
private ProviderService $providerService,
private ProvisioningService $provisioningService,
private LdapService $ldapService,
+ private SettingsService $settingsService,
private IUserManager $userManager,
private ServerVersion $serverVersion,
) {
@@ -314,6 +316,8 @@ public function getCurrentUserId(): string {
$discovery = $this->discoveryService->obtainDiscovery($provider);
$this->eventDispatcher->dispatchTyped(new TokenValidatedEvent(['token' => $headerToken], $provider, $discovery));
+ $tokenUserId = $this->settingsService->parseUserId($tokenUserId);
+
if ($autoProvisionAllowed) {
// look for user in other backends
if (!$this->userManager->userExists($tokenUserId)) {
From 8123a16f806a5de1088445817ca49067e21131da Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Mon, 22 Dec 2025 15:49:52 +0100
Subject: [PATCH 20/25] make debug endpoints private
Signed-off-by: Julien Veyssier
---
lib/Controller/ApiController.php | 2 --
1 file changed, 2 deletions(-)
diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php
index f51e653c..54a97d79 100644
--- a/lib/Controller/ApiController.php
+++ b/lib/Controller/ApiController.php
@@ -101,7 +101,6 @@ public function getJwks(): JSONResponse {
}
#[NoCSRFRequired]
- #[PublicPage]
public function debugJwk(): JSONResponse {
try {
return new JSONResponse($this->jwkService->debug());
@@ -111,7 +110,6 @@ public function debugJwk(): JSONResponse {
}
#[NoCSRFRequired]
- #[PublicPage]
public function debugJwe(): JSONResponse {
try {
return new JSONResponse($this->jweService->debug());
From f3f8c97681ac6daea0d4fc060756322a5c071e92 Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Mon, 22 Dec 2025 15:58:57 +0100
Subject: [PATCH 21/25] handle errors when parsing JWT header at the end of the
code flow
Signed-off-by: Julien Veyssier
---
lib/Controller/LoginController.php | 16 ++++++++++++----
1 file changed, 12 insertions(+), 4 deletions(-)
diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php
index 1066c773..dc9cdcb3 100644
--- a/lib/Controller/LoginController.php
+++ b/lib/Controller/LoginController.php
@@ -505,15 +505,23 @@ public function code(string $state = '', string $code = '', string $scope = '',
$this->logger->debug('Received code response');
$this->eventDispatcher->dispatchTyped(new TokenObtainedEvent($data, $provider, $discovery));
- // TODO: proper error handling
$idTokenRaw = $data['id_token'];
if ($usePrivateKeyJwt) {
- // we could check the header there
// if kid is our private JWK, we have a JWE to decrypt
// if typ=JWT, we have a classic JWT to decode
$jwtParts = explode('.', $idTokenRaw, 3);
- $jwtHeader = json_decode(JWT::urlsafeB64Decode($jwtParts[0]), true);
- $this->logger->warning('JWT HEADER', ['jwt_header' => $jwtHeader]);
+ try {
+ $jwtHeader = json_decode(JWT::urlsafeB64Decode($jwtParts[0]), true, flags: JSON_THROW_ON_ERROR);
+ } catch (\JsonException $e) {
+ $this->logger->error('Malformed JWT id token header', ['exception' => $e]);
+ $message = $this->l10n->t('Failed to decode JWT id token header');
+ return $this->buildErrorTemplateResponse($message, Http::STATUS_BAD_REQUEST, throttle: false);
+ } catch (\Exception|\Throwable $e) {
+ $this->logger->error('Impossible to decode JWT id token header', ['exception' => $e]);
+ $message = $this->l10n->t('Failed to decode JWT id token header');
+ return $this->buildErrorTemplateResponse($message, Http::STATUS_BAD_REQUEST, throttle: false);
+ }
+ $this->logger->debug('JWT HEADER', ['jwt_header' => $jwtHeader]);
if (isset($jwtHeader['typ']) && $jwtHeader['typ'] === 'JWT') {
// we have a JWT, do nothing
} elseif (isset($jwtHeader['cty']) && $jwtHeader['cty'] === 'JWT') {
From 41be3fe6c210f59b1b0b4d806e7b7e89417dfdf1 Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Mon, 22 Dec 2025 16:33:56 +0100
Subject: [PATCH 22/25] include leading and trailing / in the userId regexp
Signed-off-by: Julien Veyssier
---
README.md | 6 +++---
lib/Service/SettingsService.php | 11 +++++++++--
2 files changed, 12 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index 51759b34..5d6d6efa 100644
--- a/README.md
+++ b/README.md
@@ -97,17 +97,17 @@ you can configure user_oidc to apply a regular expression to extract the user ID
```php
'user_oidc' => [
- 'user_id_regexp' => 'u=([^,]+)'
+ 'user_id_regexp' => '/u=([^,]+)/'
]
```
This regular expression may or may not contain parenthesis. If it does, only the selected block will be extracted.
Examples:
-* `'[a-zA-Z]+'`
+* `'/[a-zA-Z]+/'`
* `'123-abc-123'` will give `'abc'`
* `'123-abc-345-def'` will give `'abc'`
-* `'u=([^,]+)'`
+* `'/u=([^,]+)/'`
* `'u=123'` will give `'123'`
* `'anything,u=123,anything'` will give `'123'`
* `'anything,u=123,anything,u=345'` will give `'123'`
diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php
index 177f7c96..a87628fd 100644
--- a/lib/Service/SettingsService.php
+++ b/lib/Service/SettingsService.php
@@ -46,8 +46,15 @@ public function setAllowMultipleUserBackEnds(bool $value): void {
public function parseUserId(string $userId): string {
$oidcSystemConfig = $this->config->getSystemValue('user_oidc', []);
- if (isset($oidcSystemConfig['user_id_regexp']) && $oidcSystemConfig['user_id_regexp'] !== '') {
- if (preg_match('/' . $oidcSystemConfig['user_id_regexp'] . '/', $userId, $matches) === 1) {
+ if (isset($oidcSystemConfig['user_id_regexp']) && is_string($oidcSystemConfig['user_id_regexp']) && $oidcSystemConfig['user_id_regexp'] !== '') {
+ // check that the regexp is valid
+ if (preg_match('/^\/.+\/[a-z]*$/', $oidcSystemConfig['user_id_regexp'], $matches) === 1) {
+ $this->logger->warning(
+ 'Incorrect "user_id_regexp", it should start and end with a "/" and have optional trailing modifiers',
+ ['regexp' => $oidcSystemConfig['user_id_regexp']],
+ );
+ }
+ if (preg_match($oidcSystemConfig['user_id_regexp'], $userId, $matches) === 1) {
return $matches[1] ?? $matches[0];
}
}
From 4fc9d406abd3ca98ab9892f7976c93be1d0f3a34 Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Thu, 25 Dec 2025 13:14:48 +0100
Subject: [PATCH 23/25] implement encryption key rotation for JWE decryption
Signed-off-by: Julien Veyssier
---
lib/Service/JweService.php | 29 +++++++++++++--
lib/Service/JwkService.php | 53 ++++++++++++++++++---------
tests/unit/Service/JweServiceTest.php | 7 +++-
tests/unit/Service/JwkServiceTest.php | 8 ++--
4 files changed, 72 insertions(+), 25 deletions(-)
diff --git a/lib/Service/JweService.php b/lib/Service/JweService.php
index 5aa2189f..4a2aac89 100644
--- a/lib/Service/JweService.php
+++ b/lib/Service/JweService.php
@@ -24,6 +24,8 @@
use Jose\Component\Encryption\JWETokenSupport;
use Jose\Component\Encryption\Serializer\CompactSerializer;
use Jose\Component\Encryption\Serializer\JWESerializerManager;
+use OCP\AppFramework\Services\IAppConfig;
+use Psr\Log\LoggerInterface;
class JweService {
@@ -31,6 +33,8 @@ class JweService {
public function __construct(
private JwkService $jwkService,
+ private IAppConfig $appConfig,
+ private LoggerInterface $logger,
) {
}
@@ -165,9 +169,28 @@ public function decryptSerializedJwe(string $serializedJwe): string {
$myPemEncryptionKey = $this->jwkService->getMyEncryptionKey(true);
$sslEncryptionKey = openssl_pkey_get_private($myPemEncryptionKey);
$sslEncryptionKeyDetails = openssl_pkey_get_details($sslEncryptionKey);
- $encPrivJwk = $this->jwkService->getJwkFromSslKey($sslEncryptionKeyDetails, isEncryptionKey: true, includePrivateKey: true);
-
- return $this->decryptSerializedJweWithKey($serializedJwe, $encPrivJwk);
+ $encryptionPrivateJwk = $this->jwkService->getJwkFromSslKey($sslEncryptionKeyDetails, isEncryptionKey: true, includePrivateKey: true);
+
+ try {
+ return $this->decryptSerializedJweWithKey($serializedJwe, $encryptionPrivateJwk);
+ } catch (\Exception $e) {
+ // try the old expired key
+ $oldPemEncryptionKey = $this->appConfig->getAppValueString(JwkService::PEM_EXPIRED_ENC_KEY_SETTINGS_KEY, lazy: true);
+ $oldPemEncryptionKeyCreatedAt = $this->appConfig->getAppValueInt(JwkService::PEM_EXPIRED_ENC_KEY_CREATED_AT_SETTINGS_KEY, lazy: true);
+ if ($oldPemEncryptionKey === '' || $oldPemEncryptionKeyCreatedAt === 0) {
+ $this->logger->debug('JWE decryption failed with a fresh key and there is no old key');
+ throw $e;
+ }
+ // the old encryption key is expired for more than an hour, we can't use it
+ if (time() > $oldPemEncryptionKeyCreatedAt + JwkService::PEM_ENC_KEY_EXPIRES_AFTER_SECONDS + (60 * 60)) {
+ $this->logger->debug('JWE decryption failed with a fresh key and the old key is expired for more than an hour');
+ throw $e;
+ }
+ $oldSslEncryptionKey = openssl_pkey_get_private($oldPemEncryptionKey);
+ $oldSslEncryptionKeyDetails = openssl_pkey_get_details($oldSslEncryptionKey);
+ $oldEncryptionPrivateJwk = $this->jwkService->getJwkFromSslKey($oldSslEncryptionKeyDetails, isEncryptionKey: true, includePrivateKey: true);
+ return $this->decryptSerializedJweWithKey($serializedJwe, $oldEncryptionPrivateJwk);
+ }
}
public function createSerializedJwe(string $payload): string {
diff --git a/lib/Service/JwkService.php b/lib/Service/JwkService.php
index 3ec3c199..7a7940e7 100644
--- a/lib/Service/JwkService.php
+++ b/lib/Service/JwkService.php
@@ -19,16 +19,19 @@
class JwkService {
public const PEM_SIG_KEY_SETTINGS_KEY = 'pemSignatureKey';
- public const PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY = 'pemSignatureKeyExpiresAt';
- public const PEM_SIG_KEY_EXPIRES_IN_SECONDS = 60 * 60;
+ public const PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY = 'pemSignatureKeyCreatedAt';
+ public const PEM_SIG_KEY_EXPIRES_AFTER_SECONDS = 60 * 60;
public const PEM_SIG_KEY_ALGORITHM = 'ES384';
public const PEM_SIG_KEY_CURVE = 'P-384';
public const PEM_ENC_KEY_SETTINGS_KEY = 'pemEncryptionKey';
- public const PEM_ENC_KEY_EXPIRES_AT_SETTINGS_KEY = 'pemEncryptionKeyExpiresAt';
- public const PEM_ENC_KEY_EXPIRES_IN_SECONDS = 60 * 60;
+ public const PEM_ENC_KEY_CREATED_AT_SETTINGS_KEY = 'pemEncryptionKeyCreatedAt';
+ public const PEM_ENC_KEY_EXPIRES_AFTER_SECONDS = 60 * 60;
public const PEM_ENC_KEY_ALGORITHM = 'ECDH-ES+A192KW';
public const PEM_ENC_KEY_CURVE = 'P-384';
+ // we store the expired encryption key and can use it for one extra hour after a new one has been generated
+ public const PEM_EXPIRED_ENC_KEY_SETTINGS_KEY = 'pemExpiredEncryptionKey';
+ public const PEM_EXPIRED_ENC_KEY_CREATED_AT_SETTINGS_KEY = 'pemExpiredEncryptionKeyCreatedAt';
public function __construct(
private IAppConfig $appConfig,
@@ -44,13 +47,15 @@ public function __construct(
*/
public function getMyPemSignatureKey(bool $refresh = true): string {
$pemSignatureKey = $this->appConfig->getAppValueString(self::PEM_SIG_KEY_SETTINGS_KEY, lazy: true);
- $pemSignatureKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
+ $pemSignatureKeyCreatedAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY, lazy: true);
- if ($pemSignatureKey === '' || $pemSignatureKeyExpiresAt === 0 || ($refresh && time() > $pemSignatureKeyExpiresAt)) {
+ if ($pemSignatureKey === ''
+ || $pemSignatureKeyCreatedAt === 0
+ || ($refresh && (time() > $pemSignatureKeyCreatedAt + self::PEM_SIG_KEY_EXPIRES_AFTER_SECONDS))) {
$pemSignatureKey = $this->generatePemPrivateKey();
// store the key
$this->appConfig->setAppValueString(self::PEM_SIG_KEY_SETTINGS_KEY, $pemSignatureKey, lazy: true);
- $this->appConfig->setAppValueInt(self::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, time() + self::PEM_SIG_KEY_EXPIRES_IN_SECONDS, lazy: true);
+ $this->appConfig->setAppValueInt(self::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY, time(), lazy: true);
}
return $pemSignatureKey;
}
@@ -64,13 +69,24 @@ public function getMyPemSignatureKey(bool $refresh = true): string {
*/
public function getMyEncryptionKey(bool $refresh = true): string {
$pemEncryptionKey = $this->appConfig->getAppValueString(self::PEM_ENC_KEY_SETTINGS_KEY, lazy: true);
- $pemEncryptionKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_ENC_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
-
- if ($pemEncryptionKey === '' || $pemEncryptionKeyExpiresAt === 0 || ($refresh && time() > $pemEncryptionKeyExpiresAt)) {
+ $pemEncryptionKeyCreatedAt = $this->appConfig->getAppValueInt(self::PEM_ENC_KEY_CREATED_AT_SETTINGS_KEY, lazy: true);
+
+ if ($pemEncryptionKey === ''
+ || $pemEncryptionKeyCreatedAt === 0
+ || ($refresh && (time() > $pemEncryptionKeyCreatedAt + self::PEM_ENC_KEY_EXPIRES_AFTER_SECONDS))
+ ) {
+ // if we have an old expired key, keep it for one hour
+ if ($pemEncryptionKey !== ''
+ && $pemEncryptionKeyCreatedAt !== 0
+ && (time() > $pemEncryptionKeyCreatedAt + self::PEM_ENC_KEY_EXPIRES_AFTER_SECONDS)) {
+ $this->appConfig->setAppValueString(self::PEM_EXPIRED_ENC_KEY_SETTINGS_KEY, $pemEncryptionKey, lazy: true);
+ $this->appConfig->setAppValueInt(self::PEM_EXPIRED_ENC_KEY_CREATED_AT_SETTINGS_KEY, $pemEncryptionKeyCreatedAt, lazy: true);
+ }
+ // generate a new key
$pemEncryptionKey = $this->generatePemPrivateKey();
// store the key
$this->appConfig->setAppValueString(self::PEM_ENC_KEY_SETTINGS_KEY, $pemEncryptionKey, lazy: true);
- $this->appConfig->setAppValueInt(self::PEM_ENC_KEY_EXPIRES_AT_SETTINGS_KEY, time() + self::PEM_ENC_KEY_EXPIRES_IN_SECONDS, lazy: true);
+ $this->appConfig->setAppValueInt(self::PEM_ENC_KEY_CREATED_AT_SETTINGS_KEY, time(), lazy: true);
}
return $pemEncryptionKey;
}
@@ -122,11 +138,14 @@ public function getJwks(): array {
}
public function getJwkFromSslKey(array $sslKeyDetails, bool $isEncryptionKey = false, bool $includePrivateKey = false): array {
- $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
+ $pemPrivateKeyCreatedAt = $this->appConfig->getAppValueInt(
+ $isEncryptionKey ? self::PEM_ENC_KEY_CREATED_AT_SETTINGS_KEY : self::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY,
+ lazy: true,
+ );
$jwk = [
'kty' => 'EC',
'use' => $isEncryptionKey ? 'enc' : 'sig',
- 'kid' => ($isEncryptionKey ? 'enc' : 'sig') . '_key_' . $pemPrivateKeyExpiresAt,
+ 'kid' => ($isEncryptionKey ? 'enc' : 'sig') . '_key_' . $pemPrivateKeyCreatedAt,
'crv' => $isEncryptionKey ? self::PEM_ENC_KEY_CURVE : self::PEM_SIG_KEY_CURVE,
'x' => \rtrim(\strtr(\base64_encode($sslKeyDetails['ec']['x']), '+/', '-_'), '='),
'y' => \rtrim(\strtr(\base64_encode($sslKeyDetails['ec']['y']), '+/', '-_'), '='),
@@ -155,7 +174,7 @@ public function generateClientAssertion(Provider $provider, string $discoveryIss
// we refresh (if needed) here to make sure we use a key that will be served to the IdP in a few seconds
$myPemPrivateKey = $this->getMyPemSignatureKey();
$sslPrivateKey = openssl_pkey_get_private($myPemPrivateKey);
- $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
+ $pemSignatureKeyCreatedAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY, lazy: true);
$payload = [
'sub' => $provider->getClientId(),
@@ -170,7 +189,7 @@ public function generateClientAssertion(Provider $provider, string $discoveryIss
$payload['code'] = $code;
}
- return $this->createJwt($payload, $sslPrivateKey, 'sig_key_' . $pemPrivateKeyExpiresAt, self::PEM_SIG_KEY_ALGORITHM);
+ return $this->createJwt($payload, $sslPrivateKey, 'sig_key_' . $pemSignatureKeyCreatedAt, self::PEM_SIG_KEY_ALGORITHM);
}
public function debug(): array {
@@ -180,8 +199,8 @@ public function debug(): array {
$pubKeyPem = $pubKey['key'];
$payload = ['lll' => 'aaa'];
- $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
- $signedJwtToken = $this->createJwt($payload, $sslPrivateKey, 'sig_key_' . $pemPrivateKeyExpiresAt, self::PEM_SIG_KEY_ALGORITHM);
+ $pemSignatureKeyCreatedAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY, lazy: true);
+ $signedJwtToken = $this->createJwt($payload, $sslPrivateKey, 'sig_key_' . $pemSignatureKeyCreatedAt, self::PEM_SIG_KEY_ALGORITHM);
// check content of JWT
$rawJwks = ['keys' => [$this->getJwkFromSslKey($pubKey)]];
diff --git a/tests/unit/Service/JweServiceTest.php b/tests/unit/Service/JweServiceTest.php
index 8efc3c41..4e91cbb1 100644
--- a/tests/unit/Service/JweServiceTest.php
+++ b/tests/unit/Service/JweServiceTest.php
@@ -13,6 +13,7 @@
use OCP\AppFramework\Services\IAppConfig;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
class JweServiceTest extends TestCase {
@@ -27,7 +28,11 @@ public function setUp(): void {
parent::setUp();
$this->appConfig = $this->createMock(IAppConfig::class);
$this->jwkService = new JwkService($this->appConfig);
- $this->jweService = new JweService($this->jwkService);
+ $this->jweService = new JweService(
+ $this->jwkService,
+ $this->appConfig,
+ $this->createMock(LoggerInterface::class),
+ );
}
public function testJweEncryptionDecryption() {
diff --git a/tests/unit/Service/JwkServiceTest.php b/tests/unit/Service/JwkServiceTest.php
index 98b7350f..d97d6742 100644
--- a/tests/unit/Service/JwkServiceTest.php
+++ b/tests/unit/Service/JwkServiceTest.php
@@ -39,8 +39,8 @@ public function testSignatureKeyAndJwt() {
$this->assertStringContainsString('-----END PRIVATE KEY-----', $myPemPrivateKey);
$initialPayload = ['nice' => 'example'];
- $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(JwkService::PEM_SIG_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
- $jwkId = 'sig_key_' . $pemPrivateKeyExpiresAt;
+ $pemSignatureKeyCreatedAt = $this->appConfig->getAppValueInt(JwkService::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY, lazy: true);
+ $jwkId = 'sig_key_' . $pemSignatureKeyCreatedAt;
$signedJwtToken = $this->jwkService->createJwt($initialPayload, $sslPrivateKey, $jwkId, JwkService::PEM_SIG_KEY_ALGORITHM);
// check JWK
@@ -72,8 +72,8 @@ public function testEncryptionKey() {
$sslEncryptionKeyDetails = openssl_pkey_get_details($sslEncryptionKey);
$encJwk = $this->jwkService->getJwkFromSslKey($sslEncryptionKeyDetails, isEncryptionKey: true);
- $pemPrivateKeyExpiresAt = $this->appConfig->getAppValueInt(JwkService::PEM_ENC_KEY_EXPIRES_AT_SETTINGS_KEY, lazy: true);
- $encJwkId = 'enc_key_' . $pemPrivateKeyExpiresAt;
+ $pemEncryptionKeyCreatedAt = $this->appConfig->getAppValueInt(JwkService::PEM_ENC_KEY_CREATED_AT_SETTINGS_KEY, lazy: true);
+ $encJwkId = 'enc_key_' . $pemEncryptionKeyCreatedAt;
$this->assertEquals('EC', $encJwk['kty']);
$this->assertEquals('enc', $encJwk['use']);
From 12950b514836a90242a4b5fb2b00f06aa9cb066e Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Thu, 26 Mar 2026 14:12:10 +0100
Subject: [PATCH 24/25] do not log sensitive data, fix user_id regexp logic,
only decrypt client secret if needed
Signed-off-by: Julien Veyssier
---
lib/Controller/LoginController.php | 26 +++++++++++++++-----------
lib/Service/SettingsService.php | 4 +++-
2 files changed, 18 insertions(+), 12 deletions(-)
diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php
index dc9cdcb3..d16bc169 100644
--- a/lib/Controller/LoginController.php
+++ b/lib/Controller/LoginController.php
@@ -394,14 +394,6 @@ public function code(string $state = '', string $code = '', string $scope = '',
$providerId = (int)$this->session->get(self::PROVIDERID);
$provider = $this->providerMapper->getProvider($providerId);
- try {
- $providerClientSecret = $this->crypto->decrypt($provider->getClientSecret());
- } catch (\Exception $e) {
- $this->logger->error('Failed to decrypt the client secret', ['exception' => $e]);
- $message = $this->l10n->t('Failed to decrypt the OIDC provider client secret');
- return $this->buildErrorTemplateResponse($message, Http::STATUS_BAD_REQUEST, [], false);
- }
-
$discovery = $this->discoveryService->obtainDiscovery($provider);
$this->logger->debug('Obtainting data from: ' . $discovery['token_endpoint']);
@@ -443,6 +435,13 @@ public function code(string $state = '', string $code = '', string $scope = '',
// private key JWT auth does not work with client_secret_basic, we don't wanna pass the client secret
if ($tokenEndpointAuthMethod === 'client_secret_basic' && !$usePrivateKeyJwt) {
+ try {
+ $providerClientSecret = $this->crypto->decrypt($provider->getClientSecret());
+ } catch (\Exception $e) {
+ $this->logger->error('Failed to decrypt the client secret', ['exception' => $e]);
+ $message = $this->l10n->t('Failed to decrypt the OIDC provider client secret');
+ return $this->buildErrorTemplateResponse($message, Http::STATUS_BAD_REQUEST, [], false);
+ }
$headers = [
'Authorization' => 'Basic ' . base64_encode($provider->getClientId() . ':' . $providerClientSecret),
'Content-Type' => 'application/x-www-form-urlencoded',
@@ -454,6 +453,13 @@ public function code(string $state = '', string $code = '', string $scope = '',
$requestBody['client_assertion_type'] = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
$requestBody['client_assertion'] = $this->jwkService->generateClientAssertion($provider, $discovery['issuer'], $code);
} else {
+ try {
+ $providerClientSecret = $this->crypto->decrypt($provider->getClientSecret());
+ } catch (\Exception $e) {
+ $this->logger->error('Failed to decrypt the client secret', ['exception' => $e]);
+ $message = $this->l10n->t('Failed to decrypt the OIDC provider client secret');
+ return $this->buildErrorTemplateResponse($message, Http::STATUS_BAD_REQUEST, [], false);
+ }
$requestBody['client_secret'] = $providerClientSecret;
}
}
@@ -527,10 +533,8 @@ public function code(string $state = '', string $code = '', string $scope = '',
} elseif (isset($jwtHeader['cty']) && $jwtHeader['cty'] === 'JWT') {
// we have a JWE that contains the JWT string (the ID token)
$idTokenRaw = $this->jweService->decryptSerializedJwe($idTokenRaw);
- $this->logger->warning('raw decrypted JWE', ['decrypted_jwe' => $idTokenRaw]);
- $this->logger->warning('decrypted+decoded JWE', ['decrypted_jwe' => json_decode($idTokenRaw, true)]);
} else {
- $this->logger->warning('Unsupported id_token when using "private key JWT"', ['id_token' => $idTokenRaw]);
+ $this->logger->warning('Unsupported id_token when using "private key JWT"');
}
}
$jwks = $this->discoveryService->obtainJWK($provider, $idTokenRaw);
diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php
index a87628fd..d09198bb 100644
--- a/lib/Service/SettingsService.php
+++ b/lib/Service/SettingsService.php
@@ -48,12 +48,14 @@ public function parseUserId(string $userId): string {
$oidcSystemConfig = $this->config->getSystemValue('user_oidc', []);
if (isset($oidcSystemConfig['user_id_regexp']) && is_string($oidcSystemConfig['user_id_regexp']) && $oidcSystemConfig['user_id_regexp'] !== '') {
// check that the regexp is valid
- if (preg_match('/^\/.+\/[a-z]*$/', $oidcSystemConfig['user_id_regexp'], $matches) === 1) {
+ if (preg_match('/^\/.+\/[a-z]*$/', $oidcSystemConfig['user_id_regexp']) !== 1) {
$this->logger->warning(
'Incorrect "user_id_regexp", it should start and end with a "/" and have optional trailing modifiers',
['regexp' => $oidcSystemConfig['user_id_regexp']],
);
+ return $userId;
}
+ // return the potential bracket block, fallback to the entire match
if (preg_match($oidcSystemConfig['user_id_regexp'], $userId, $matches) === 1) {
return $matches[1] ?? $matches[0];
}
From 1e57757504ac0fce3654201295e7a86a2e7860c5 Mon Sep 17 00:00:00 2001
From: Julien Veyssier
Date: Thu, 26 Mar 2026 14:56:14 +0100
Subject: [PATCH 25/25] [WARNING] needs logic check: improve signature key
rotation
Signed-off-by: Julien Veyssier
---
lib/Service/JwkService.php | 116 +++++++++++++++++++++-----
tests/unit/Service/JwkServiceTest.php | 57 +++++++++++++
2 files changed, 151 insertions(+), 22 deletions(-)
diff --git a/lib/Service/JwkService.php b/lib/Service/JwkService.php
index 7a7940e7..8517d35d 100644
--- a/lib/Service/JwkService.php
+++ b/lib/Service/JwkService.php
@@ -20,6 +20,8 @@ class JwkService {
public const PEM_SIG_KEY_SETTINGS_KEY = 'pemSignatureKey';
public const PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY = 'pemSignatureKeyCreatedAt';
+ public const PEM_NEXT_SIG_KEY_SETTINGS_KEY = 'pemNextSignatureKey';
+ public const PEM_NEXT_SIG_KEY_CREATED_AT_SETTINGS_KEY = 'pemNextSignatureKeyCreatedAt';
public const PEM_SIG_KEY_EXPIRES_AFTER_SECONDS = 60 * 60;
public const PEM_SIG_KEY_ALGORITHM = 'ES384';
public const PEM_SIG_KEY_CURVE = 'P-384';
@@ -39,25 +41,16 @@ public function __construct(
}
/**
- * Get our stored signature PEM key (or regenerate it if it's expired)
+ * Get our current signature PEM key after applying rotation if needed.
*
- * @param bool $refresh
* @return string
* @throws AppConfigTypeConflictException
*/
- public function getMyPemSignatureKey(bool $refresh = true): string {
- $pemSignatureKey = $this->appConfig->getAppValueString(self::PEM_SIG_KEY_SETTINGS_KEY, lazy: true);
- $pemSignatureKeyCreatedAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY, lazy: true);
+ public function getMyPemSignatureKey(): string {
+ $this->ensureSignatureKeysInitialized();
+ $this->rotateSignatureKeysIfNeeded();
- if ($pemSignatureKey === ''
- || $pemSignatureKeyCreatedAt === 0
- || ($refresh && (time() > $pemSignatureKeyCreatedAt + self::PEM_SIG_KEY_EXPIRES_AFTER_SECONDS))) {
- $pemSignatureKey = $this->generatePemPrivateKey();
- // store the key
- $this->appConfig->setAppValueString(self::PEM_SIG_KEY_SETTINGS_KEY, $pemSignatureKey, lazy: true);
- $this->appConfig->setAppValueInt(self::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY, time(), lazy: true);
- }
- return $pemSignatureKey;
+ return $this->appConfig->getAppValueString(self::PEM_SIG_KEY_SETTINGS_KEY, lazy: true);
}
/**
@@ -123,22 +116,34 @@ public function generatePemPrivateKey(): string {
* @throws AppConfigTypeConflictException
*/
public function getJwks(): array {
- // we don't refresh here to make sure the IdP will get the key that was used to sign the client assertion
- $myPemSignatureKey = $this->getMyPemSignatureKey(false);
+ $myPemSignatureKey = $this->getMyPemSignatureKey();
+ $pemSignatureKeyCreatedAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY, lazy: true);
$sslSignatureKey = openssl_pkey_get_private($myPemSignatureKey);
$sslSignatureKeyDetails = openssl_pkey_get_details($sslSignatureKey);
+ $myNextPemSignatureKey = $this->appConfig->getAppValueString(self::PEM_NEXT_SIG_KEY_SETTINGS_KEY, lazy: true);
+ $nextPemSignatureKeyCreatedAt = $this->appConfig->getAppValueInt(self::PEM_NEXT_SIG_KEY_CREATED_AT_SETTINGS_KEY, lazy: true);
+ $sslNextSignatureKey = openssl_pkey_get_private($myNextPemSignatureKey);
+ $sslNextSignatureKeyDetails = openssl_pkey_get_details($sslNextSignatureKey);
+
$myPemEncryptionKey = $this->getMyEncryptionKey(true);
+ $pemEncryptionKeyCreatedAt = $this->appConfig->getAppValueInt(self::PEM_ENC_KEY_CREATED_AT_SETTINGS_KEY, lazy: true);
$sslEncryptionKey = openssl_pkey_get_private($myPemEncryptionKey);
$sslEncryptionKeyDetails = openssl_pkey_get_details($sslEncryptionKey);
return [
- $this->getJwkFromSslKey($sslSignatureKeyDetails),
- $this->getJwkFromSslKey($sslEncryptionKeyDetails, isEncryptionKey: true),
+ $this->getJwkFromSslKey($sslSignatureKeyDetails, keyCreatedAt: $pemSignatureKeyCreatedAt),
+ $this->getJwkFromSslKey($sslNextSignatureKeyDetails, keyCreatedAt: $nextPemSignatureKeyCreatedAt),
+ $this->getJwkFromSslKey($sslEncryptionKeyDetails, isEncryptionKey: true, keyCreatedAt: $pemEncryptionKeyCreatedAt),
];
}
- public function getJwkFromSslKey(array $sslKeyDetails, bool $isEncryptionKey = false, bool $includePrivateKey = false): array {
- $pemPrivateKeyCreatedAt = $this->appConfig->getAppValueInt(
+ public function getJwkFromSslKey(
+ array $sslKeyDetails,
+ bool $isEncryptionKey = false,
+ bool $includePrivateKey = false,
+ ?int $keyCreatedAt = null,
+ ): array {
+ $pemPrivateKeyCreatedAt = $keyCreatedAt ?? $this->appConfig->getAppValueInt(
$isEncryptionKey ? self::PEM_ENC_KEY_CREATED_AT_SETTINGS_KEY : self::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY,
lazy: true,
);
@@ -171,7 +176,7 @@ public function createJwt(array $payload, \OpenSSLAsymmetricKey $key, string $ke
}
public function generateClientAssertion(Provider $provider, string $discoveryIssuer, ?string $code = null): string {
- // we refresh (if needed) here to make sure we use a key that will be served to the IdP in a few seconds
+ // make sure we sign with the currently active key, while the next one is already pre-published
$myPemPrivateKey = $this->getMyPemSignatureKey();
$sslPrivateKey = openssl_pkey_get_private($myPemPrivateKey);
$pemSignatureKeyCreatedAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY, lazy: true);
@@ -213,7 +218,7 @@ public function debug(): array {
$jwtHeader = json_decode(JWT::urlsafeB64Decode($jwtParts[0]), true);
return [
- 'public_jwk' => $this->getJwkFromSslKey($pubKey),
+ 'public_jwk' => $this->getJwkFromSslKey($pubKey, keyCreatedAt: $pemSignatureKeyCreatedAt),
'public_pem' => $pubKeyPem,
'private_pem' => $myPemPrivateKey,
'initial_payload' => $payload,
@@ -223,4 +228,71 @@ public function debug(): array {
'arrays_are_equal' => $payload === $jwtPayloadArray,
];
}
+
+ private function ensureSignatureKeysInitialized(): void {
+ $currentPem = $this->appConfig->getAppValueString(self::PEM_SIG_KEY_SETTINGS_KEY, lazy: true);
+ $currentCreatedAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY, lazy: true);
+ $nextPem = $this->appConfig->getAppValueString(self::PEM_NEXT_SIG_KEY_SETTINGS_KEY, lazy: true);
+ $nextCreatedAt = $this->appConfig->getAppValueInt(self::PEM_NEXT_SIG_KEY_CREATED_AT_SETTINGS_KEY, lazy: true);
+
+ if ($currentPem === '' || $currentCreatedAt === 0) {
+ if ($nextPem !== '' && $nextCreatedAt !== 0) {
+ $this->storeSignatureKey(self::PEM_SIG_KEY_SETTINGS_KEY, self::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY, $nextPem, $nextCreatedAt);
+ $currentPem = $nextPem;
+ $currentCreatedAt = $nextCreatedAt;
+ $nextPem = '';
+ $nextCreatedAt = 0;
+ } else {
+ $currentCreatedAt = $this->getFreshKeyCreatedAt();
+ $currentPem = $this->generatePemPrivateKey();
+ $this->storeSignatureKey(self::PEM_SIG_KEY_SETTINGS_KEY, self::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY, $currentPem, $currentCreatedAt);
+ }
+ }
+
+ if ($nextPem === '' || $nextCreatedAt === 0) {
+ $nextCreatedAt = $this->getFreshKeyCreatedAt($currentCreatedAt);
+ $nextPem = $this->generatePemPrivateKey();
+ $this->storeSignatureKey(self::PEM_NEXT_SIG_KEY_SETTINGS_KEY, self::PEM_NEXT_SIG_KEY_CREATED_AT_SETTINGS_KEY, $nextPem, $nextCreatedAt);
+ }
+ }
+
+ private function rotateSignatureKeysIfNeeded(): void {
+ $currentCreatedAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY, lazy: true);
+ $nextPem = $this->appConfig->getAppValueString(self::PEM_NEXT_SIG_KEY_SETTINGS_KEY, lazy: true);
+ $nextCreatedAt = $this->appConfig->getAppValueInt(self::PEM_NEXT_SIG_KEY_CREATED_AT_SETTINGS_KEY, lazy: true);
+
+ if (!$this->shouldRotateSignatureKeys($currentCreatedAt, $nextCreatedAt) || $nextPem === '') {
+ return;
+ }
+
+ $this->storeSignatureKey(self::PEM_SIG_KEY_SETTINGS_KEY, self::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY, $nextPem, $nextCreatedAt);
+
+ $freshNextCreatedAt = $this->getFreshKeyCreatedAt($nextCreatedAt);
+ $this->storeSignatureKey(
+ self::PEM_NEXT_SIG_KEY_SETTINGS_KEY,
+ self::PEM_NEXT_SIG_KEY_CREATED_AT_SETTINGS_KEY,
+ $this->generatePemPrivateKey(),
+ $freshNextCreatedAt,
+ );
+ }
+
+ private function shouldRotateSignatureKeys(int $currentCreatedAt, int $nextCreatedAt): bool {
+ if ($currentCreatedAt === 0 || $nextCreatedAt === 0) {
+ return false;
+ }
+
+ $now = time();
+ return $now > $currentCreatedAt + self::PEM_SIG_KEY_EXPIRES_AFTER_SECONDS
+ && $now > $nextCreatedAt + self::PEM_SIG_KEY_EXPIRES_AFTER_SECONDS;
+ }
+
+ private function getFreshKeyCreatedAt(int ...$existingCreatedAt): int {
+ $latestExistingCreatedAt = $existingCreatedAt === [] ? 0 : max($existingCreatedAt);
+ return max(time(), $latestExistingCreatedAt + 1);
+ }
+
+ private function storeSignatureKey(string $keySetting, string $createdAtSetting, string $pemKey, int $createdAt): void {
+ $this->appConfig->setAppValueString($keySetting, $pemKey, lazy: true);
+ $this->appConfig->setAppValueInt($createdAtSetting, $createdAt, lazy: true);
+ }
}
diff --git a/tests/unit/Service/JwkServiceTest.php b/tests/unit/Service/JwkServiceTest.php
index d97d6742..cdce9474 100644
--- a/tests/unit/Service/JwkServiceTest.php
+++ b/tests/unit/Service/JwkServiceTest.php
@@ -8,6 +8,7 @@
declare(strict_types=1);
+use OCA\UserOIDC\Db\Provider;
use OCA\UserOIDC\Service\JwkService;
use OCA\UserOIDC\Vendor\Firebase\JWT\JWK;
use OCA\UserOIDC\Vendor\Firebase\JWT\JWT;
@@ -21,10 +22,30 @@ class JwkServiceTest extends TestCase {
private $appConfig;
/** @var JwkService|MockObject */
private $jwkService;
+ private array $appConfigStrings = [];
+ private array $appConfigInts = [];
public function setUp(): void {
parent::setUp();
$this->appConfig = $this->createMock(IAppConfig::class);
+ $this->appConfig->method('getAppValueString')
+ ->willReturnCallback(function (string $key, ?string $default = null, bool $lazy = false) {
+ return $this->appConfigStrings[$key] ?? ($default ?? '');
+ });
+ $this->appConfig->method('getAppValueInt')
+ ->willReturnCallback(function (string $key, ?int $default = null, bool $lazy = false) {
+ return $this->appConfigInts[$key] ?? ($default ?? 0);
+ });
+ $this->appConfig->method('setAppValueString')
+ ->willReturnCallback(function (string $key, string $value, bool $lazy = false) {
+ $this->appConfigStrings[$key] = $value;
+ return true;
+ });
+ $this->appConfig->method('setAppValueInt')
+ ->willReturnCallback(function (string $key, int $value, bool $lazy = false) {
+ $this->appConfigInts[$key] = $value;
+ return true;
+ });
$this->jwkService = new JwkService($this->appConfig);
}
@@ -81,4 +102,40 @@ public function testEncryptionKey() {
$this->assertEquals(JwkService::PEM_ENC_KEY_CURVE, $encJwk['crv']);
$this->assertEquals(JwkService::PEM_ENC_KEY_ALGORITHM, $encJwk['alg']);
}
+
+ public function testJwksContainsCurrentAndNextSignatureKeys(): void {
+ $jwks = $this->jwkService->getJwks();
+
+ $this->assertCount(3, $jwks);
+ $this->assertSame('sig', $jwks[0]['use']);
+ $this->assertSame('sig', $jwks[1]['use']);
+ $this->assertSame('enc', $jwks[2]['use']);
+ $this->assertNotSame($jwks[0]['kid'], $jwks[1]['kid']);
+ }
+
+ public function testExpiredCurrentSignatureKeyPromotesPrepublishedNextKey(): void {
+ $oldCurrentKey = $this->jwkService->generatePemPrivateKey();
+ $oldNextKey = $this->jwkService->generatePemPrivateKey();
+ $oldCurrentCreatedAt = time() - JwkService::PEM_SIG_KEY_EXPIRES_AFTER_SECONDS - 20;
+ $oldNextCreatedAt = time() - JwkService::PEM_SIG_KEY_EXPIRES_AFTER_SECONDS - 10;
+
+ $this->appConfigStrings[JwkService::PEM_SIG_KEY_SETTINGS_KEY] = $oldCurrentKey;
+ $this->appConfigInts[JwkService::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY] = $oldCurrentCreatedAt;
+ $this->appConfigStrings[JwkService::PEM_NEXT_SIG_KEY_SETTINGS_KEY] = $oldNextKey;
+ $this->appConfigInts[JwkService::PEM_NEXT_SIG_KEY_CREATED_AT_SETTINGS_KEY] = $oldNextCreatedAt;
+
+ $provider = new Provider();
+ $provider->setClientId('client-id');
+
+ $this->jwkService->generateClientAssertion($provider, 'https://issuer.example');
+
+ $this->assertSame($oldNextKey, $this->appConfigStrings[JwkService::PEM_SIG_KEY_SETTINGS_KEY]);
+ $this->assertSame($oldNextCreatedAt, $this->appConfigInts[JwkService::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY]);
+ $this->assertNotSame($oldNextKey, $this->appConfigStrings[JwkService::PEM_NEXT_SIG_KEY_SETTINGS_KEY]);
+ $this->assertGreaterThan($oldNextCreatedAt, $this->appConfigInts[JwkService::PEM_NEXT_SIG_KEY_CREATED_AT_SETTINGS_KEY]);
+
+ $jwks = $this->jwkService->getJwks();
+ $this->assertSame('sig_key_' . $oldNextCreatedAt, $jwks[0]['kid']);
+ $this->assertNotSame($jwks[0]['kid'], $jwks[1]['kid']);
+ }
}