From 8d2dbef04259a54b2d4e768119ba6d1b038796a5 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 1 Oct 2025 14:53:08 +0200 Subject: [PATCH 01/23] 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 a8c6bb52..909f2a2c 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 24fc41d7..c273d8f1 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,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 f36fa294..ddf17c63 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": "62be19eb0dc43640d32350e3d43c6bf0", + "content-hash": "52f029a74514042a7716ebe169f2ab90", "packages": [ { "name": "bamarni/composer-bin-plugin", @@ -279,6 +279,60 @@ } ], "time": "2025-12-15T11:48:50+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 fcfea413..1ff1764e 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -10,9 +10,11 @@ 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\PublicPage; use OCP\AppFramework\Http\DataResponse; use OCP\Files\IRootFolder; use OCP\Files\NotPermittedException; @@ -26,6 +28,7 @@ public function __construct( private IRootFolder $root, private UserMapper $userMapper, private IUserManager $userManager, + private JwkService $jwkService, ) { parent::__construct(Application::APP_ID, $request); } @@ -84,4 +87,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 8a0795ac9f29a929a6c5fed4ff02b58289655c81 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Thu, 9 Oct 2025 10:02:35 +0200 Subject: [PATCH 02/23] 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 1ff1764e..156790f2 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -16,6 +16,7 @@ use OCP\AppFramework\Http\Attribute\NoCSRFRequired; 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; @@ -90,12 +91,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 b274792794f0d2ed8fa108473488b170f3736fb7 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Tue, 28 Oct 2025 19:09:29 +0100 Subject: [PATCH 03/23] 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 486d0517f5e93f7888b5e9efaaeb8ad006d7fe19 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 29 Oct 2025 11:33:25 +0100 Subject: [PATCH 04/23] 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 156790f2..13fc2994 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -93,8 +93,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 c73bfb8e1ac26e6c25d0f1f9870f17eb27604a9e Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 29 Oct 2025 12:45:24 +0100 Subject: [PATCH 05/23] 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 908bb42288bee010abfb8eae382e2e62df3afe3d Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 29 Oct 2025 12:59:41 +0100 Subject: [PATCH 06/23] 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 f6690046..1f79638b 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 bfda134b..908d7a9b 100644 --- a/lib/Service/ProviderService.php +++ b/lib/Service/ProviderService.php @@ -18,6 +18,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'; @@ -62,6 +63,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_RESOLVE_NESTED_AND_FALLBACK_CLAIMS_MAPPING => false, @@ -169,6 +171,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 379a82ea..a55ec148 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 7205a7bc..5657a28a 100644 --- a/tests/unit/Service/ProviderServiceTest.php +++ b/tests/unit/Service/ProviderServiceTest.php @@ -89,6 +89,7 @@ public function testGetProvidersWithSettings() { 'mappingBirthdate' => '1', 'uniqueUid' => true, 'checkBearer' => true, + 'usePrivateKeyJwt' => true, 'bearerProvisioning' => true, 'sendIdTokenHint' => true, 'extraClaims' => '1', @@ -135,6 +136,7 @@ public function testGetProvidersWithSettings() { 'mappingBirthdate' => '1', 'uniqueUid' => true, 'checkBearer' => true, + 'usePrivateKeyJwt' => true, 'bearerProvisioning' => true, 'sendIdTokenHint' => true, 'extraClaims' => '1', @@ -157,6 +159,7 @@ public function testSetSettings() { 'mappingGroups' => 'groups', 'uniqueUid' => true, 'checkBearer' => false, + 'usePrivateKeyJwt' => false, 'bearerProvisioning' => false, 'sendIdTokenHint' => true, 'extraClaims' => 'claim1 claim2', @@ -216,6 +219,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 b7cf717a9b38a3d3c184ae57c3ed1eacd684764e Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 29 Oct 2025 16:48:49 +0100 Subject: [PATCH 07/23] 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 a9e4f951..00fc9b2d 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; @@ -96,6 +97,7 @@ public function __construct( private ICrypto $crypto, private TokenService $tokenService, private OidcService $oidcService, + private JwkService $jwkService, ) { parent::__construct($request, $config, $l10n); } @@ -384,6 +386,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 = [ @@ -413,7 +416,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', @@ -421,7 +425,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 55a657dff8f2611298f446d32777ffa177bf7e3e Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 29 Oct 2025 17:25:40 +0100 Subject: [PATCH 08/23] 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 d7ffa757..b96932d8 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,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/23] 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 59a94604edd8962cd7fdf45050f9e8cb6bd5b8a3 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Tue, 4 Nov 2025 17:16:58 +0100 Subject: [PATCH 10/23] 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 13fc2994..013eeab1 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -94,8 +94,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 7691b7e8952240b8cc429c76e563ed811c41b7b4 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Tue, 4 Nov 2025 17:35:26 +0100 Subject: [PATCH 11/23] 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 013eeab1..13fc2994 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -94,8 +94,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 a0955c140f6daa90b28c0794e4d5177f490e90c1 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Thu, 6 Nov 2025 16:10:30 +0100 Subject: [PATCH 12/23] 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 909f2a2c..a80ba247 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 c273d8f1..0f8c2360 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,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 ddf17c63..13f280a5 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": "52f029a74514042a7716ebe169f2ab90", + "content-hash": "cbc1a8dd9c0eddff8bd47cf385765b23", "packages": [ { "name": "bamarni/composer-bin-plugin", @@ -63,6 +63,66 @@ }, "time": "2025-11-24T19:20:55+00:00" }, + { + "name": "brick/math", + "version": "0.14.1", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "f05858549e5f9d7bb45875a75583240a38a281d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/f05858549e5f9d7bb45875a75583240a38a281d0", + "reference": "f05858549e5f9d7bb45875a75583240a38a281d0", + "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.1" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2025-11-24T14:40:29+00:00" + }, { "name": "firebase/php-jwt", "version": "v7.0.1", @@ -281,33 +341,88 @@ "time": "2025-12-15T11:48:50+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/", @@ -316,23 +431,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": "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.0", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "bf6f55a9d9eb25b7781640221cb54f5c727850d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/bf6f55a9d9eb25b7781640221cb54f5c727850d7", + "reference": "bf6f55a9d9eb25b7781640221cb54f5c727850d7", + "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" ], - "description": "A small PHP library to handle JWKs (Json Web Keys)", - "homepage": "https://github.com/Strobotti/php-jwk", + "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.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2025-10-22T08:24:34+00:00" + }, + { + "name": "web-token/jwt-library", + "version": "4.1.2", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-library.git", + "reference": "621ff3ec618c6a34f63d47e467cefe8788871d6f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-library/zipball/621ff3ec618c6a34f63d47e467cefe8788871d6f", + "reference": "621ff3ec618c6a34f63d47e467cefe8788871d6f", + "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.2" }, - "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-11-17T21:14:49+00:00" } ], "packages-dev": [ @@ -1230,54 +1558,6 @@ }, "time": "2025-12-10T09:38:52+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 13fc2994..58c9713b 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -10,6 +10,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; @@ -30,6 +31,7 @@ public function __construct( private UserMapper $userMapper, private IUserManager $userManager, private JwkService $jwkService, + private JweService $jweService, ) { parent::__construct(Application::APP_ID, $request); } @@ -95,7 +97,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 43d2ab0aa21ba1b9f392d8726418f19a26a6ec15 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Thu, 6 Nov 2025 16:18:27 +0100 Subject: [PATCH 13/23] 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 ec009a7daa4d6122c51bb9d68376670a4752c3ca Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Thu, 6 Nov 2025 18:00:39 +0100 Subject: [PATCH 14/23] 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 2220d2ec..57de2440 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 d0e2afa1a1268997a263c4d4ceb7b29c1b6d5bff Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 19 Nov 2025 12:20:20 +0100 Subject: [PATCH 15/23] tests Signed-off-by: Julien Veyssier --- lib/Controller/LoginController.php | 22 ++++++++++++++++++- lib/Service/JweService.php | 31 +++++++++++++++++++++++---- tests/unit/Service/JweServiceTest.php | 4 ++-- 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/lib/Controller/LoginController.php b/lib/Controller/LoginController.php index 00fc9b2d..df1d9428 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; @@ -98,6 +99,7 @@ public function __construct( private TokenService $tokenService, private OidcService $oidcService, private JwkService $jwkService, + private JweService $jweService, ) { parent::__construct($request, $config, $l10n); } @@ -193,6 +195,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); @@ -461,11 +465,27 @@ public function code(string $state = '', string $code = '', string $scope = '', } $data = json_decode($body, true); - $this->logger->debug('Received code response: ' . json_encode($data, JSON_THROW_ON_ERROR)); + $this->logger->warning('Received code response: ' . json_encode($data, JSON_THROW_ON_ERROR)); $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]); + 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 f8aefebd50464afbdecb31c90f5fe36907ca82dc Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Mon, 24 Nov 2025 15:27:44 +0100 Subject: [PATCH 16/23] 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 df1d9428..777f8779 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -478,13 +478,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 99e6b9c739b1824909aa52864d73d975ea01a288 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 26 Nov 2025 10:30:51 +0100 Subject: [PATCH 17/23] 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 b96932d8..840eb6e8 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,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 777f8779..dd633c82 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -195,10 +195,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 @@ -389,7 +390,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 b7454057a4d4ce095baadc3b058c7614e1108dca Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 26 Nov 2025 10:49:37 +0100 Subject: [PATCH 18/23] 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 619e0e56..ea083ac0 100644 --- a/lib/Controller/BaseOidcController.php +++ b/lib/Controller/BaseOidcController.php @@ -53,13 +53,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 dd633c82..e01bd844 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -347,7 +347,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 706ac1d75db96eedb00a757172173b6a69c5473e Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Thu, 27 Nov 2025 14:37:57 +0100 Subject: [PATCH 19/23] 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 840eb6e8..3a4c1fb3 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 e01bd844..b078779f 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -570,6 +570,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 548e0350..f4434ba0 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\SelfEncodedValidator; use OCA\UserOIDC\User\Validator\UserInfoValidator; use OCP\AppFramework\Db\DoesNotExistException; @@ -59,6 +60,7 @@ public function __construct( private ProviderService $providerService, private ProvisioningService $provisioningService, private LdapService $ldapService, + private SettingsService $settingsService, private IUserManager $userManager, ) { } @@ -289,6 +291,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 1ce410a6f900ecaa66e7b26cfc12e350bebef9a3 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Mon, 22 Dec 2025 15:49:52 +0100 Subject: [PATCH 20/23] 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 58c9713b..d1cb4cbb 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -103,7 +103,6 @@ public function getJwks(): JSONResponse { } #[NoCSRFRequired] - #[PublicPage] public function debugJwk(): JSONResponse { try { return new JSONResponse($this->jwkService->debug()); @@ -113,7 +112,6 @@ public function debugJwk(): JSONResponse { } #[NoCSRFRequired] - #[PublicPage] public function debugJwe(): JSONResponse { try { return new JSONResponse($this->jweService->debug()); From 5d218719242b78eb4fdc4bfb6853b8766d5242b6 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Mon, 22 Dec 2025 15:58:57 +0100 Subject: [PATCH 21/23] 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 b078779f..dba2865b 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -471,15 +471,23 @@ public function code(string $state = '', string $code = '', string $scope = '', $this->logger->warning('Received code response: ' . json_encode($data, JSON_THROW_ON_ERROR)); $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 6162d2917c2ff46329312c9bd9b3e2099e684f69 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Mon, 22 Dec 2025 16:33:56 +0100 Subject: [PATCH 22/23] 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 3a4c1fb3..c3e1bc78 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 76023aafcb66ac37b293eb0f3e6ee796c333930e Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Thu, 25 Dec 2025 13:14:48 +0100 Subject: [PATCH 23/23] 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 +-- .../unit/Service/ProvisioningServiceTest.php | 1 - 5 files changed, 72 insertions(+), 26 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']); diff --git a/tests/unit/Service/ProvisioningServiceTest.php b/tests/unit/Service/ProvisioningServiceTest.php index a0ec353c..319d0268 100644 --- a/tests/unit/Service/ProvisioningServiceTest.php +++ b/tests/unit/Service/ProvisioningServiceTest.php @@ -248,7 +248,6 @@ public function testProvisionUserInvalidProperties(): void { $property->method('getName')->willReturn('twitter'); $property->method('getScope')->willReturn(IAccountManager::SCOPE_LOCAL); $property->method('getValue')->willReturnCallback(function () use (&$twitterProperty) { - echo 'GETTING: ' . $twitterProperty; return $twitterProperty; });