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 diff --git a/README.md b/README.md index d7ffa757..c3e1bc78 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. @@ -202,6 +224,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). @@ -215,6 +253,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/appinfo/info.xml b/appinfo/info.xml index 7079790e..22e939d6 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/appinfo/routes.php b/appinfo/routes.php index a8c6bb52..a80ba247 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -19,6 +19,9 @@ ['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 24fc41d7..0f8c2360 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,9 @@ "require": { "id4me/id4me-rp": "^1.2", "firebase/php-jwt": "^7", - "bamarni/composer-bin-plugin": "^1.4" + "bamarni/composer-bin-plugin": "^1.4", + "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 f36fa294..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": "62be19eb0dc43640d32350e3d43c6bf0", + "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", @@ -279,6 +339,328 @@ } ], "time": "2025-12-15T11:48:50+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": "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": ">=8.0" + }, + "require-dev": { + "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": { + "AESKW\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "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" + ], + "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", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "issues": "https://github.com/web-token/jwt-library/issues", + "source": "https://github.com/web-token/jwt-library/tree/4.1.2" + }, + "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": [ @@ -1176,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/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/Controller/ApiController.php b/lib/Controller/ApiController.php index fcfea413..d1cb4cbb 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -10,10 +10,14 @@ 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; 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; @@ -26,6 +30,8 @@ public function __construct( private IRootFolder $root, private UserMapper $userMapper, private IUserManager $userManager, + private JwkService $jwkService, + private JweService $jweService, ) { parent::__construct(Application::APP_ID, $request); } @@ -84,4 +90,33 @@ public function deleteUser(string $userId): DataResponse { $user->delete(); return new DataResponse(['user_id' => $userId], Http::STATUS_OK); } + + #[NoCSRFRequired] + #[PublicPage] + public function getJwks(): JSONResponse { + try { + $jwks = $this->jwkService->getJwks(); + return new JSONResponse(['keys' => $jwks]); + } catch (\Exception|\Throwable $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + #[NoCSRFRequired] + 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] + 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/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 a9e4f951..dba2865b 100644 --- a/lib/Controller/LoginController.php +++ b/lib/Controller/LoginController.php @@ -21,6 +21,8 @@ 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; use OCA\UserOIDC\Service\ProviderService; @@ -96,6 +98,8 @@ public function __construct( private ICrypto $crypto, private TokenService $tokenService, private OidcService $oidcService, + private JwkService $jwkService, + private JweService $jweService, ) { parent::__construct($request, $config, $l10n); } @@ -191,8 +195,11 @@ public function login(int $providerId, ?string $redirectUrl = null) { $this->session->set(self::NONCE, $nonce); $oidcSystemConfig = $this->config->getSystemValue('user_oidc', []); + // 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 @@ -340,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); @@ -383,7 +390,10 @@ 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 { $requestBody = [ @@ -413,7 +423,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 +432,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( @@ -452,11 +468,37 @@ 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) { + // 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); + 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') { + // 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]); + } + } $jwks = $this->discoveryService->obtainJWK($provider, $idTokenRaw); JWT::$leeway = 60; try { @@ -536,6 +578,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/JweService.php b/lib/Service/JweService.php new file mode 100644 index 00000000..4a2aac89 --- /dev/null +++ b/lib/Service/JweService.php @@ -0,0 +1,242 @@ +create() // We want to create a new JWE + ->withPayload($payload) // We set the payload + ->withSharedProtectedHeader([ + // Key Encryption Algorithm + // 'alg' => 'A256KW', + 'alg' => $keyEncryptionAlg, + // Content Encryption Algorithm + // '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(); + // We serialize the recipient at index 0 (we only have one recipient). + return $serializer->serialize($jwe, 0); + } + + /** + * @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 decryptSerializedJweWithKey(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 decryptSerializedJwe(string $serializedJwe): string { + $myPemEncryptionKey = $this->jwkService->getMyEncryptionKey(true); + $sslEncryptionKey = openssl_pkey_get_private($myPemEncryptionKey); + $sslEncryptionKeyDetails = openssl_pkey_get_details($sslEncryptionKey); + $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 { + $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($payload, $encPublicJwk); + } + + public function debug(): array { + $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->createSerializedJweWithKey(json_encode($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/lib/Service/JwkService.php b/lib/Service/JwkService.php new file mode 100644 index 00000000..7a7940e7 --- /dev/null +++ b/lib/Service/JwkService.php @@ -0,0 +1,226 @@ +appConfig->getAppValueString(self::PEM_SIG_KEY_SETTINGS_KEY, lazy: true); + $pemSignatureKeyCreatedAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY, lazy: true); + + if ($pemSignatureKey === '' + || $pemSignatureKeyCreatedAt === 0 + || ($refresh && (time() > $pemSignatureKeyCreatedAt + self::PEM_SIG_KEY_EXPIRES_AFTER_SECONDS))) { + $pemSignatureKey = $this->generatePemPrivateKey(); + // store the key + $this->appConfig->setAppValueString(self::PEM_SIG_KEY_SETTINGS_KEY, $pemSignatureKey, lazy: true); + $this->appConfig->setAppValueInt(self::PEM_SIG_KEY_CREATED_AT_SETTINGS_KEY, time(), lazy: true); + } + return $pemSignatureKey; + } + + /** + * Get our stored encryption PEM key (or regenerate it if it's expired) + * + * @param bool $refresh + * @return string + * @throws AppConfigTypeConflictException + */ + public function getMyEncryptionKey(bool $refresh = true): string { + $pemEncryptionKey = $this->appConfig->getAppValueString(self::PEM_ENC_KEY_SETTINGS_KEY, lazy: true); + $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_CREATED_AT_SETTINGS_KEY, time(), lazy: true); + } + return $pemEncryptionKey; + } + + /** + * Generate a new full/private key and return it in PEM format + * + * @return string + */ + public function generatePemPrivateKey(): string { + $config = [ + // 'digest_alg' => 'ES384', + // '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, $privateKeyPem); + + return $privateKeyPem; + } + + /** + * 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 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); + + $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), + ]; + } + + public function getJwkFromSslKey(array $sslKeyDetails, bool $isEncryptionKey = false, bool $includePrivateKey = false): array { + $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_' . $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']), '+/', '-_'), '='), + '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; + } + + /** + * 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 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); + $pemSignatureKeyCreatedAt = $this->appConfig->getAppValueInt(self::PEM_SIG_KEY_CREATED_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_' . $pemSignatureKeyCreatedAt, self::PEM_SIG_KEY_ALGORITHM); + } + + public function debug(): array { + $myPemPrivateKey = $this->getMyPemSignatureKey(); + $sslPrivateKey = openssl_pkey_get_private($myPemPrivateKey); + $pubKey = openssl_pkey_get_details($sslPrivateKey); + $pubKeyPem = $pubKey['key']; + + $payload = ['lll' => 'aaa']; + $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)]]; + $jwks = JWK::parseKeySet($rawJwks, self::PEM_SIG_KEY_ALGORITHM); + $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->getJwkFromSslKey($pubKey), + 'public_pem' => $pubKeyPem, + 'private_pem' => $myPemPrivateKey, + 'initial_payload' => $payload, + 'signed_jwt' => $signedJwtToken, + 'jwt_header' => $jwtHeader, + 'decoded_jwt_payload' => $jwtPayloadArray, + 'arrays_are_equal' => $payload === $jwtPayloadArray, + ]; + } +} 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/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index ffd75d34..a87628fd 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,21 @@ 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']) && 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]; + } + } + 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)) { 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 4e79259b..e06014c8 100644 --- a/src/components/SettingsForm.vue +++ b/src/components/SettingsForm.vue @@ -23,13 +23,32 @@ type="text" required>

+
+ + {{ t('user_oidc', 'Use private key JWT authentication method') }} + + + + + + +
+ + {{ t('user_oidc', 'Use this JWKS URL in your IdP\'s client settings: {jwksUrl}', { jwksUrl }) }} +

@@ -349,19 +368,25 @@ import AlertOutlineIcon from 'vue-material-design-icons/AlertOutline.vue' import CheckIcon from 'vue-material-design-icons/Check.vue' import ChevronRightIcon from 'vue-material-design-icons/ChevronRight.vue' import ChevronDownIcon from 'vue-material-design-icons/ChevronDown.vue' +import HelpCircleOutlineIcon from 'vue-material-design-icons/HelpCircleOutline.vue' import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' import NcButton from '@nextcloud/vue/components/NcButton' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' + +import { generateUrl } from '@nextcloud/router' export default { name: 'SettingsForm', components: { NcCheckboxRadioSwitch, NcButton, + NcNoteCard, AlertOutlineIcon, CheckIcon, ChevronRightIcon, ChevronDownIcon, + HelpCircleOutlineIcon, }, props: { submitText: { @@ -386,12 +411,21 @@ export default { localProvider: null, maxIdentifierLength: 128, showProfileAttributes: false, + jwksUrl: window.location.protocol + '//' + window.location.host + generateUrl('/apps/user_oidc/jwks'), } }, computed: { 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 @@ -429,6 +463,12 @@ export default { } } + .line { + display: flex; + align-items: center; + margin: 4px 0; + } + .warning-hint { margin-left: 160px; background-color: var(--color-background-dark); 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 '

    '; + } ?>

    diff --git a/tests/unit/Service/JweServiceTest.php b/tests/unit/Service/JweServiceTest.php new file mode 100644 index 00000000..4e91cbb1 --- /dev/null +++ b/tests/unit/Service/JweServiceTest.php @@ -0,0 +1,60 @@ +appConfig = $this->createMock(IAppConfig::class); + $this->jwkService = new JwkService($this->appConfig); + $this->jweService = new JweService( + $this->jwkService, + $this->appConfig, + $this->createMock(LoggerInterface::class), + ); + } + + 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', + ]; + $inputPayload = json_encode($inputPayloadArray); + + $serializedJweToken = $this->jweService->createSerializedJweWithKey($inputPayload, $encPublicJwk); + $decryptedJweString = $this->jweService->decryptSerializedJweWithKey($serializedJweToken, $encPrivJwk); + + $outputPayloadArray = json_decode($decryptedJweString, true); + $this->assertEquals($inputPayloadArray, $outputPayloadArray); + } +} diff --git a/tests/unit/Service/JwkServiceTest.php b/tests/unit/Service/JwkServiceTest.php new file mode 100644 index 00000000..d97d6742 --- /dev/null +++ b/tests/unit/Service/JwkServiceTest.php @@ -0,0 +1,84 @@ +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']; + $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 + $jwk = $this->jwkService->getJwkFromSslKey($pubKey); + $this->assertEquals('EC', $jwk['kty']); + $this->assertEquals('sig', $jwk['use']); + $this->assertEquals($jwkId, $jwk['kid']); + $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, JwkService::PEM_SIG_KEY_ALGORITHM); + $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(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); + + $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']); + $this->assertEquals($encJwkId, $encJwk['kid']); + $this->assertEquals(JwkService::PEM_ENC_KEY_CURVE, $encJwk['crv']); + $this->assertEquals(JwkService::PEM_ENC_KEY_ALGORITHM, $encJwk['alg']); + } +} 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'], 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; });