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;
});