diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ddf0f30..5a41f41f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - name: 🛠️ Setup PHP uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: - php-version: "8.1" + php-version: "8.3" tools: composer, cs2pr - name: 🔍 Validate composer.json and composer.lock diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 62e0ba5f..68b8de8a 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: - php-version: [8.1, 7.4] + php-version: [8.4, 8.3] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/README.md b/README.md index 65caf7af..aa80c4b0 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,32 @@ print_r($response); 6. `DescopeSDK->getClaims($sessionToken)` - will return all of the claims from the JWT in an array format. 7. `DescopeSDK->getUserDetails($refreshToken)` - will return all of the user information (email, phone, verification status, etc.) using a provided refresh token. +### DPoP Sender-Constrained Tokens (RFC 9449) + +When a Descope session token contains a `cnf.jkt` claim it is DPoP-bound, meaning every request must include a signed `DPoP` proof JWT that demonstrates possession of the corresponding private key. + +Use `validateDPoP` **after** a successful `verify` call to enforce the sender-constraint: + +```php +// 1. Verify the session token as usual +$descopeSDK->verify($sessionToken); + +// 2. If the token is DPoP-bound, validate the DPoP proof +// (does nothing if the token has no cnf.jkt claim) +$descopeSDK->validateDPoP( + $sessionToken, // the verified session JWT + $_SERVER['HTTP_DPOP'] ?? '', // DPoP header sent by the client (empty string if absent) + $_SERVER['REQUEST_METHOD'], // e.g. "GET" or "POST" + 'https://example.com/api/resource' // full request URL +); +``` + +`validateDPoP` throws `\Descope\SDK\Exception\TokenException` if: + +- The session token is DPoP-bound but no proof is provided. +- The proof signature, `htm` (method), `htu` (URL), `iat` (timestamp), or `ath` (access token hash) is invalid. +- The proof key does not match the `cnf.jkt` thumbprint in the session token. + ### User Management Functions Each of these functions have code examples on how to use them. diff --git a/composer.json b/composer.json index b5217cef..310a3931 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ } ], "require": { - "php": "^7.3 || ^8.0", + "php": "^8.3", "guzzlehttp/guzzle": "7.9.2 as 7.9.3", "paragonie/constant_time_encoding": "2.8.2", "vlucas/phpdotenv": "^5.6.1" @@ -34,7 +34,7 @@ "ignore": ["PKSA-z3gr-8qht-p93v"] }, "platform": { - "php": "7.3.0" + "php": "8.3.0" } }, "scripts": { diff --git a/composer.lock b/composer.lock index 92c2ffe9..10ce5894 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": "f38df7c3d53702992315237293ce6912", + "content-hash": "935ee65899be4b2102f2ba6c4e9891b9", "packages": [ { "name": "graham-campbell/result-type", @@ -1145,76 +1145,6 @@ } ], "packages-dev": [ - { - "name": "doctrine/instantiator", - "version": "1.5.0", - "source": { - "type": "git", - "url": "https://github.com/doctrine/instantiator.git", - "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b", - "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b", - "shasum": "" - }, - "require": { - "php": "^7.1 || ^8.0" - }, - "require-dev": { - "doctrine/coding-standard": "^9 || ^11", - "ext-pdo": "*", - "ext-phar": "*", - "phpbench/phpbench": "^0.16 || ^1", - "phpstan/phpstan": "^1.4", - "phpstan/phpstan-phpunit": "^1", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "vimeo/psalm": "^4.30 || ^5.4" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com", - "homepage": "https://ocramius.github.io/" - } - ], - "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", - "homepage": "https://www.doctrine-project.org/projects/instantiator.html", - "keywords": [ - "constructor", - "instantiate" - ], - "support": { - "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/1.5.0" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", - "type": "tidelift" - } - ], - "time": "2022-12-30T00:15:36+00:00" - }, { "name": "myclabs/deep-copy", "version": "1.13.4", @@ -1277,30 +1207,37 @@ }, { "name": "nikic/php-parser", - "version": "v4.19.5", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "51bd93cc741b7fc3d63d20b6bdcd99fdaa359837" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/51bd93cc741b7fc3d63d20b6bdcd99fdaa359837", - "reference": "51bd93cc741b7fc3d63d20b6bdcd99fdaa359837", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { + "ext-ctype": "*", + "ext-json": "*", "ext-tokenizer": "*", - "php": ">=7.1" + "php": ">=7.4" }, "require-dev": { "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" + "phpunit/phpunit": "^9.0" }, "bin": [ "bin/php-parse" ], "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, "autoload": { "psr-4": { "PhpParser\\": "lib/PhpParser" @@ -1322,9 +1259,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.19.5" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2025-12-06T11:45:25+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "phar-io/manifest", @@ -1446,35 +1383,33 @@ }, { "name": "phpunit/php-code-coverage", - "version": "9.2.32", + "version": "12.5.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" + "reference": "876099a072646c7745f673d7aeab5382c4439691" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", - "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691", + "reference": "876099a072646c7745f673d7aeab5382c4439691", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.19.1 || ^5.1.0", - "php": ">=7.3", - "phpunit/php-file-iterator": "^3.0.6", - "phpunit/php-text-template": "^2.0.4", - "sebastian/code-unit-reverse-lookup": "^2.0.3", - "sebastian/complexity": "^2.0.3", - "sebastian/environment": "^5.1.5", - "sebastian/lines-of-code": "^1.0.4", - "sebastian/version": "^3.0.2", - "theseer/tokenizer": "^1.2.3" + "nikic/php-parser": "^5.7.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.0.3", + "sebastian/lines-of-code": "^4.0", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^12.5.1" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -1483,7 +1418,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "9.2.x-dev" + "dev-main": "12.5.x-dev" } }, "autoload": { @@ -1512,40 +1447,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" } ], - "time": "2024-08-22T04:23:01+00:00" + "time": "2026-04-15T08:23:17+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "3.0.6", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", - "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -1572,36 +1519,49 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2021-12-02T12:48:52+00:00" + "time": "2026-02-02T14:04:18+00:00" }, { "name": "phpunit/php-invoker", - "version": "3.1.1", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", - "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "suggest": { "ext-pcntl": "*" @@ -1609,7 +1569,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.1-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -1635,7 +1595,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-invoker/issues", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" }, "funding": [ { @@ -1643,32 +1604,32 @@ "type": "github" } ], - "time": "2020-09-28T05:58:55+00:00" + "time": "2025-02-07T04:58:58+00:00" }, { "name": "phpunit/php-text-template", - "version": "2.0.4", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", - "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -1694,7 +1655,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-text-template/issues", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" }, "funding": [ { @@ -1702,32 +1664,32 @@ "type": "github" } ], - "time": "2020-10-26T05:33:50+00:00" + "time": "2025-02-07T04:59:16+00:00" }, { "name": "phpunit/php-timer", - "version": "5.0.3", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", - "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -1753,7 +1715,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-timer/issues", - "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" }, "funding": [ { @@ -1761,24 +1724,23 @@ "type": "github" } ], - "time": "2020-10-26T13:16:10+00:00" + "time": "2025-02-07T04:59:38+00:00" }, { "name": "phpunit/phpunit", - "version": "9.6.33", + "version": "12.5.25", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "fea06253ecc0a32faf787bd31b261f56f351d049" + "reference": "792c2980442dfce319226b88fa845b8b6de3b333" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fea06253ecc0a32faf787bd31b261f56f351d049", - "reference": "fea06253ecc0a32faf787bd31b261f56f351d049", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/792c2980442dfce319226b88fa845b8b6de3b333", + "reference": "792c2980442dfce319226b88fa845b8b6de3b333", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.5.0 || ^2", "ext-dom": "*", "ext-json": "*", "ext-libxml": "*", @@ -1788,27 +1750,23 @@ "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", - "php": ">=7.3", - "phpunit/php-code-coverage": "^9.2.32", - "phpunit/php-file-iterator": "^3.0.6", - "phpunit/php-invoker": "^3.1.1", - "phpunit/php-text-template": "^2.0.4", - "phpunit/php-timer": "^5.0.3", - "sebastian/cli-parser": "^1.0.2", - "sebastian/code-unit": "^1.0.8", - "sebastian/comparator": "^4.0.10", - "sebastian/diff": "^4.0.6", - "sebastian/environment": "^5.1.5", - "sebastian/exporter": "^4.0.8", - "sebastian/global-state": "^5.0.8", - "sebastian/object-enumerator": "^4.0.4", - "sebastian/resource-operations": "^3.0.4", - "sebastian/type": "^3.2.1", - "sebastian/version": "^3.0.2" - }, - "suggest": { - "ext-soap": "To be able to generate mocks based on WSDL files", - "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.6", + "phpunit/php-file-iterator": "^6.0.1", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.0", + "sebastian/comparator": "^7.1.6", + "sebastian/diff": "^7.0.0", + "sebastian/environment": "^8.1.0", + "sebastian/exporter": "^7.0.2", + "sebastian/global-state": "^8.0.2", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.3", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" }, "bin": [ "phpunit" @@ -1816,7 +1774,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "9.6-dev" + "dev-main": "12.5-dev" } }, "autoload": { @@ -1848,56 +1806,40 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.33" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.25" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-01-27T05:25:09+00:00" + "time": "2026-05-13T03:56:57+00:00" }, { "name": "sebastian/cli-parser", - "version": "1.0.2", + "version": "4.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", - "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "4.2-dev" } }, "autoload": { @@ -1920,153 +1862,60 @@ "homepage": "https://github.com/sebastianbergmann/cli-parser", "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - } - ], - "time": "2024-03-02T06:27:43+00:00" - }, - { - "name": "sebastian/code-unit", - "version": "1.0.8", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", - "shasum": "" - }, - "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Collection of value objects that represent the PHP code units", - "homepage": "https://github.com/sebastianbergmann/code-unit", - "support": { - "issues": "https://github.com/sebastianbergmann/code-unit/issues", - "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" - }, - "funding": [ + }, { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2020-10-26T13:08:54+00:00" - }, - { - "name": "sebastian/code-unit-reverse-lookup", - "version": "2.0.3", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", - "shasum": "" - }, - "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Looks up which function or method a line of code belongs to", - "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", - "support": { - "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", - "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" - }, - "funding": [ + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" } ], - "time": "2020-09-28T05:30:19+00:00" + "time": "2026-05-17T05:29:34+00:00" }, { "name": "sebastian/comparator", - "version": "4.0.10", + "version": "7.1.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" + "reference": "0ed818fb2660fd80d71fbb982c80153bba8da7ef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", - "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/0ed818fb2660fd80d71fbb982c80153bba8da7ef", + "reference": "0ed818fb2660fd80d71fbb982c80153bba8da7ef", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/diff": "^4.0", - "sebastian/exporter": "^4.0" + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.1-dev" } }, "autoload": { @@ -2105,7 +1954,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.7" }, "funding": [ { @@ -2125,33 +1975,33 @@ "type": "tidelift" } ], - "time": "2026-01-24T09:22:56+00:00" + "time": "2026-05-20T11:50:17+00:00" }, { "name": "sebastian/complexity", - "version": "2.0.3", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", - "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", "shasum": "" }, "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=7.3" + "nikic/php-parser": "^5.0", + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -2174,7 +2024,8 @@ "homepage": "https://github.com/sebastianbergmann/complexity", "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", - "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" }, "funding": [ { @@ -2182,33 +2033,33 @@ "type": "github" } ], - "time": "2023-12-22T06:19:30+00:00" + "time": "2025-02-07T04:55:25+00:00" }, { "name": "sebastian/diff", - "version": "4.0.6", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" + "reference": "7ab1ea946c012266ca32390913653d844ecd085f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", - "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3", - "symfony/process": "^4.2 || ^5" + "phpunit/phpunit": "^12.0", + "symfony/process": "^7.2" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -2240,7 +2091,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", - "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" }, "funding": [ { @@ -2248,27 +2100,27 @@ "type": "github" } ], - "time": "2024-03-02T06:30:58+00:00" + "time": "2025-02-07T04:55:46+00:00" }, { "name": "sebastian/environment", - "version": "5.1.5", + "version": "8.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" + "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", - "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/b121608b28a13f721e76ffbbd386d08eff58f3f6", + "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "suggest": { "ext-posix": "*" @@ -2276,7 +2128,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.1-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -2295,7 +2147,7 @@ } ], "description": "Provides functionality to handle HHVM/PHP environments", - "homepage": "http://www.github.com/sebastianbergmann/environment", + "homepage": "https://github.com/sebastianbergmann/environment", "keywords": [ "Xdebug", "environment", @@ -2303,42 +2155,55 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", - "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5" + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" } ], - "time": "2023-02-03T06:03:51+00:00" + "time": "2026-04-15T12:13:01+00:00" }, { "name": "sebastian/exporter", - "version": "4.0.8", + "version": "7.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c" + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c", - "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/recursion-context": "^4.0" + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { - "ext-mbstring": "*", - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -2380,7 +2245,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8" + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" }, "funding": [ { @@ -2400,38 +2266,35 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:03:27+00:00" + "time": "2026-05-20T04:37:17+00:00" }, { "name": "sebastian/global-state", - "version": "5.0.8", + "version": "8.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" + "reference": "ef1377171613d09edd25b7816f05be8313f9115d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", - "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", + "reference": "ef1377171613d09edd25b7816f05be8313f9115d", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^9.3" - }, - "suggest": { - "ext-uopz": "*" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -2450,13 +2313,14 @@ } ], "description": "Snapshotting of global state", - "homepage": "http://www.github.com/sebastianbergmann/global-state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", "keywords": [ "global state" ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8" + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" }, "funding": [ { @@ -2476,33 +2340,33 @@ "type": "tidelift" } ], - "time": "2025-08-10T07:10:35+00:00" + "time": "2025-08-29T11:29:25+00:00" }, { "name": "sebastian/lines-of-code", - "version": "1.0.4", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", - "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", "shasum": "" }, "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=7.3" + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -2525,42 +2389,55 @@ "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" } ], - "time": "2023-12-22T06:20:34+00:00" + "time": "2026-05-19T16:22:07+00:00" }, { "name": "sebastian/object-enumerator", - "version": "4.0.4", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -2582,7 +2459,8 @@ "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" }, "funding": [ { @@ -2590,32 +2468,32 @@ "type": "github" } ], - "time": "2020-10-26T13:12:34+00:00" + "time": "2025-02-07T04:57:48+00:00" }, { "name": "sebastian/object-reflector", - "version": "2.0.4", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -2637,7 +2515,8 @@ "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" }, "funding": [ { @@ -2645,32 +2524,32 @@ "type": "github" } ], - "time": "2020-10-26T13:14:26+00:00" + "time": "2025-02-07T04:58:17+00:00" }, { "name": "sebastian/recursion-context", - "version": "4.0.6", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "539c6691e0623af6dc6f9c20384c120f963465a0" + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0", - "reference": "539c6691e0623af6dc6f9c20384c120f963465a0", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -2700,7 +2579,8 @@ "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6" + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" }, "funding": [ { @@ -2720,86 +2600,32 @@ "type": "tidelift" } ], - "time": "2025-08-10T06:57:39+00:00" - }, - { - "name": "sebastian/resource-operations", - "version": "3.0.4", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", - "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", - "shasum": "" - }, - "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Provides a list of PHP built-in functions that operate on resources", - "homepage": "https://www.github.com/sebastianbergmann/resource-operations", - "support": { - "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-03-14T16:00:52+00:00" + "time": "2025-08-13T04:44:59+00:00" }, { "name": "sebastian/type", - "version": "3.2.1", + "version": "6.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" + "reference": "82ff822c2edc46724be9f7411d3163021f602773" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.5" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.2-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -2822,37 +2648,50 @@ "homepage": "https://github.com/sebastianbergmann/type", "support": { "issues": "https://github.com/sebastianbergmann/type/issues", - "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" } ], - "time": "2023-02-03T06:13:03+00:00" + "time": "2026-05-20T06:45:45+00:00" }, { "name": "sebastian/version", - "version": "3.0.2", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c6c1022351a901512170118436c764e473f6de8c" + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", - "reference": "c6c1022351a901512170118436c764e473f6de8c", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -2875,7 +2714,8 @@ "homepage": "https://github.com/sebastianbergmann/version", "support": { "issues": "https://github.com/sebastianbergmann/version/issues", - "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" }, "funding": [ { @@ -2883,7 +2723,7 @@ "type": "github" } ], - "time": "2020-09-28T06:39:44+00:00" + "time": "2025-02-07T05:00:38+00:00" }, { "name": "squizlabs/php_codesniffer", @@ -2964,25 +2804,77 @@ ], "time": "2025-11-10T16:43:36+00:00" }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, { "name": "theseer/tokenizer", - "version": "1.3.1", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", - "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", "shasum": "" }, "require": { "ext-dom": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.1" }, "type": "library", "autoload": { @@ -3004,7 +2896,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" }, "funding": [ { @@ -3012,7 +2904,7 @@ "type": "github" } ], - "time": "2025-11-17T20:03:58+00:00" + "time": "2025-12-08T11:19:18+00:00" } ], "aliases": [ @@ -3028,11 +2920,11 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "^7.3 || ^8.0" + "php": "^8.3" }, "platform-dev": {}, "platform-overrides": { - "php": "7.3.0" + "php": "8.3.0" }, "plugin-api-version": "2.9.0" } diff --git a/phpunit.xml b/phpunit.xml index 886c4380..aa954a94 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -7,6 +7,7 @@ src/tests/SDKConfigCacheTest.php src/tests/APIExceptionMappingTest.php src/tests/APIRetryTest.php + src/tests/DPoPTest.php \ No newline at end of file diff --git a/src/SDK/DescopeSDK.php b/src/SDK/DescopeSDK.php index ba384101..852d535d 100644 --- a/src/SDK/DescopeSDK.php +++ b/src/SDK/DescopeSDK.php @@ -5,6 +5,7 @@ use Descope\SDK\API; use Descope\SDK\Token\Extractor; use Descope\SDK\Token\Verifier; +use Descope\SDK\Token\DPoP; use Descope\SDK\Configuration\SDKConfig; use Descope\SDK\Auth\Password; use Descope\SDK\Auth\SSO; @@ -226,6 +227,27 @@ public function logoutAll(?string $refreshToken = null): void ); } + /** + * Validate a DPoP proof for a DPoP-bound session token (RFC 9449). + * + * If the session token does not contain a cnf.jkt claim, this method + * returns immediately (the token is not DPoP-bound). + * + * @param string $sessionToken The session JWT (already verified). + * @param string $dpopProof The value of the DPoP HTTP header sent by the client. + * @param string $method The HTTP method of the incoming request (e.g. "GET", "POST"). + * @param string $requestUrl The full URL of the incoming request. + * @throws \Descope\SDK\Exception\TokenException If the DPoP proof is missing or invalid. + */ + public function validateDPoP( + string $sessionToken, + string $dpopProof, + string $method, + string $requestUrl + ): void { + DPoP::validateProof($dpopProof, $method, $requestUrl, $sessionToken); + } + /** * Get the Password component. * diff --git a/src/SDK/Token/DPoP.php b/src/SDK/Token/DPoP.php new file mode 100644 index 00000000..f3b67cce --- /dev/null +++ b/src/SDK/Token/DPoP.php @@ -0,0 +1,702 @@ + self::MAX_PROOF_LEN) { + throw new TokenException('DPoP proof exceeds maximum length'); + } + + // Step 3: Require proof when token is DPoP-bound + if ($dpopProof === '') { + throw new TokenException('DPoP proof required: access token is DPoP-bound (cnf.jkt present)'); + } + + // Step 4-5: Split JWT parts + $parts = explode('.', $dpopProof); + if (count($parts) !== 3) { + throw new TokenException('malformed DPoP JWT'); + } + + // Step 6: Decode header + $header = self::base64urlDecodeJson($parts[0]); + + // Step 7: Verify typ + if (($header['typ'] ?? '') !== 'dpop+jwt') { + throw new TokenException('typ must be dpop+jwt'); + } + + // Steps 8-9: Verify alg + $alg = $header['alg'] ?? ''; + if (!in_array($alg, self::ALLOWED_ALGS, true)) { + throw new TokenException('rejected algorithm: ' . $alg); + } + + // Steps 10-13: Validate JWK header + $jwk = $header['jwk'] ?? null; + if (empty($jwk) || !is_array($jwk)) { + throw new TokenException('missing jwk header'); + } + if (($jwk['kty'] ?? '') === 'oct') { + throw new TokenException('symmetric key not allowed'); + } + if (isset($jwk['d'])) { + throw new TokenException('jwk must not contain a private key'); + } + + // Cross-check alg/kty compatibility (RFC 7518) + $kty = $jwk['kty'] ?? ''; + if (in_array($alg, ['RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512'], true) && $kty !== 'RSA') { + throw new TokenException('alg ' . $alg . ' requires kty=RSA, got kty=' . $kty); + } + if (in_array($alg, ['ES256', 'ES384', 'ES512'], true) && $kty !== 'EC') { + throw new TokenException('alg ' . $alg . ' requires kty=EC, got kty=' . $kty); + } + if ($alg === 'EdDSA' && $kty !== 'OKP') { + throw new TokenException('alg EdDSA requires kty=OKP, got kty=' . $kty); + } + + // Step 14-15: Import JWK and verify signature + $publicKey = self::importJwkAsPublicKey($jwk, $alg); + $signingInput = $parts[0] . '.' . $parts[1]; + $signature = self::base64urlDecode($parts[2]); + self::verifyDpopSignature($signingInput, $signature, $publicKey, $jwk, $alg); + + // Step 16: Decode payload + $payload = self::base64urlDecodeJson($parts[1]); + + // Steps 17-19: Validate required claims + if (empty($payload['jti'])) { + throw new TokenException('missing jti'); + } + if (empty($payload['htm'])) { + throw new TokenException('missing htm'); + } + if (empty($payload['htu'])) { + throw new TokenException('missing htu'); + } + + // Step 20: Validate htm (HTTP method) + if ($payload['htm'] !== strtoupper($method)) { + throw new TokenException('htm mismatch: expected ' . strtoupper($method) . ', got ' . $payload['htm']); + } + + // Step 21: Validate htu (HTTP URI) + if (!self::htuMatches($payload['htu'], $requestUrl)) { + throw new TokenException('htu does not match request URL'); + } + + // Steps 22-24: Validate iat (issued at) + if (!isset($payload['iat']) || !is_int($payload['iat'])) { + throw new TokenException('missing or invalid iat'); + } + $diff = time() - $payload['iat']; + if ($diff <= -(self::IAT_FORWARD_WINDOW) || $diff >= self::IAT_BACKWARD_WINDOW) { + throw new TokenException('iat out of acceptable window'); + } + + // Steps 25-30: Validate ath (access token hash) + $ath = $payload['ath'] ?? ''; + if (empty($ath)) { + throw new TokenException('missing ath claim'); + } + $expectedAth = self::base64urlEncode(hash('sha256', $sessionToken, true)); + if (!hash_equals($expectedAth, $ath)) { + throw new TokenException('ath does not match'); + } + + // Steps 31-33: Validate JWK thumbprint matches cnf.jkt + $thumbprint = self::computeJwkThumbprint($jwk); + if (!hash_equals($storedJkt, $thumbprint)) { + throw new TokenException('DPoP proof key does not match cnf.jkt'); + } + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Decode the payload of a JWT without signature verification. + */ + private static function decodeTokenClaims(string $jwt): array + { + $parts = explode('.', $jwt); + if (count($parts) !== 3) { + throw new TokenException('Invalid session token format'); + } + $payload = self::base64urlDecodeJson($parts[1]); + return $payload; + } + + /** + * Base64url-decode then JSON-decode a JWT part. + * + * @throws \Exception on decode or parse failure. + */ + private static function base64urlDecodeJson(string $data): array + { + $decoded = self::base64urlDecode($data); + $result = json_decode($decoded, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new TokenException('Invalid JSON in JWT part: ' . json_last_error_msg()); + } + if (!is_array($result)) { + throw new TokenException('JWT part JSON did not decode to an array'); + } + return $result; + } + + /** + * Base64url-decode a string. + */ + private static function base64urlDecode(string $data): string + { + $padded = str_pad( + strtr($data, '-_', '+/'), + strlen($data) + (4 - strlen($data) % 4) % 4, + '=' + ); + $decoded = base64_decode($padded, true); + if ($decoded === false) { + throw new TokenException('Invalid base64url encoding'); + } + return $decoded; + } + + /** + * Base64url-encode a string (no padding). + */ + private static function base64urlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + /** + * Import a JWK as an OpenSSL public key resource. + * + * @throws \Exception on unsupported or invalid key. + * @return resource|\OpenSSLAsymmetricKey + */ + private static function importJwkAsPublicKey(array $jwk, string $alg) + { + $kty = $jwk['kty'] ?? ''; + switch ($kty) { + case 'RSA': + return self::importRsaJwk($jwk); + case 'EC': + return self::importEcJwk($jwk); + case 'OKP': + return self::importOkpJwk($jwk); + default: + throw new TokenException('Unsupported JWK key type: ' . $kty); + } + } + + /** + * Import an RSA JWK as an OpenSSL public key (PEM via DER). + */ + private static function importRsaJwk(array $jwk) + { + if (empty($jwk['n']) || empty($jwk['e'])) { + throw new TokenException('RSA JWK missing n or e parameters'); + } + + $modulus = self::base64urlDecode($jwk['n']); + $exponent = self::base64urlDecode($jwk['e']); + + // Remove leading null bytes from modulus + $modulus = ltrim($modulus, "\x00"); + + // Build RSA public key DER (same approach as Extractor.php) + $modBytes = pack('Ca*a*', 0x02, self::derEncodeLength(strlen($modulus)), $modulus); + $expBytes = pack('Ca*a*', 0x02, self::derEncodeLength(strlen($exponent)), $exponent); + $rsaSeq = pack('Ca*a*', 0x30, self::derEncodeLength(strlen($modBytes . $expBytes)), $modBytes . $expBytes); + + $algId = pack('H*', '300d06092a864886f70d0101010500'); + $bitStr = pack('Ca*', 0x03, self::derEncodeLength(strlen($rsaSeq) + 1) . "\x00" . $rsaSeq); + $der = pack('Ca*a*', 0x30, self::derEncodeLength(strlen($algId . $bitStr)), $algId . $bitStr); + + $pem = "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END PUBLIC KEY-----\n"; + $key = openssl_pkey_get_public($pem); + if ($key === false) { + throw new TokenException('Failed to import RSA JWK as public key'); + } + return $key; + } + + /** + * Import an EC JWK as an OpenSSL public key (DER-encoded SubjectPublicKeyInfo). + * + * Supports P-256 (ES256), P-384 (ES384), P-521 (ES512). + */ + private static function importEcJwk(array $jwk) + { + $crv = $jwk['crv'] ?? ''; + if (empty($jwk['x']) || empty($jwk['y'])) { + throw new TokenException('EC JWK missing x or y parameters'); + } + + $x = self::base64urlDecode($jwk['x']); + $y = self::base64urlDecode($jwk['y']); + + // EC OIDs and expected coordinate byte lengths + $curveParams = [ + 'P-256' => ['oid' => '2a8648ce3d030107', 'len' => 32], + 'P-384' => ['oid' => '2b81040022', 'len' => 48], + 'P-521' => ['oid' => '2b81040023', 'len' => 66], + ]; + + if (!isset($curveParams[$crv])) { + throw new TokenException('Unsupported EC curve: ' . $crv); + } + + $coordLen = $curveParams[$crv]['len']; + // Pad x and y to expected length + $x = str_pad($x, $coordLen, "\x00", STR_PAD_LEFT); + $y = str_pad($y, $coordLen, "\x00", STR_PAD_LEFT); + + // Uncompressed EC point: 0x04 || x || y + $point = "\x04" . $x . $y; + $bitString = "\x00" . $point; + + // Build SubjectPublicKeyInfo DER: + // SEQUENCE { + // SEQUENCE { OID ecPublicKey, OID curve } + // BIT STRING { 0x04 || x || y } + // } + $ecPublicKeyOid = pack('H*', '2a8648ce3d0201'); // OID 1.2.840.10045.2.1 (ecPublicKey) + $curveOid = pack('H*', $curveParams[$crv]['oid']); + + $ecOidDer = pack('Ca*', 0x06, self::derEncodeLength(strlen($ecPublicKeyOid)) . $ecPublicKeyOid); + $curveOidDer = pack('Ca*', 0x06, self::derEncodeLength(strlen($curveOid)) . $curveOid); + $algSeq = pack('Ca*a*', 0x30, self::derEncodeLength(strlen($ecOidDer . $curveOidDer)), $ecOidDer . $curveOidDer); + + $bitStr = pack('Ca*a*', 0x03, self::derEncodeLength(strlen($bitString)), $bitString); + $spki = pack('Ca*a*', 0x30, self::derEncodeLength(strlen($algSeq . $bitStr)), $algSeq . $bitStr); + + $pem = "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($spki), 64, "\n") . "-----END PUBLIC KEY-----\n"; + $key = openssl_pkey_get_public($pem); + if ($key === false) { + throw new TokenException('Failed to import EC JWK as public key: ' . openssl_error_string()); + } + return $key; + } + + /** + * Import an OKP (Ed25519) JWK as an OpenSSL public key. + * Requires PHP 8.1+ with OpenSSL 1.1.1+. + */ + private static function importOkpJwk(array $jwk) + { + $crv = $jwk['crv'] ?? ''; + if ($crv !== 'Ed25519') { + throw new TokenException('Unsupported OKP curve: ' . $crv . ' (only Ed25519 is supported)'); + } + if (empty($jwk['x'])) { + throw new TokenException('OKP JWK missing x parameter'); + } + + $x = self::base64urlDecode($jwk['x']); + + // Ed25519 SubjectPublicKeyInfo DER: + // SEQUENCE { SEQUENCE { OID 1.3.101.112 } BIT STRING { key bytes } } + $oid = pack('H*', '2b6570'); // OID 1.3.101.112 (Ed25519) + $oidDer = pack('Ca*', 0x06, self::derEncodeLength(strlen($oid)) . $oid); + $algSeq = pack('Ca*', 0x30, self::derEncodeLength(strlen($oidDer)) . $oidDer); + $bitString = "\x00" . $x; + $bitStr = pack('Ca*a*', 0x03, self::derEncodeLength(strlen($bitString)), $bitString); + $spki = pack('Ca*a*', 0x30, self::derEncodeLength(strlen($algSeq . $bitStr)), $algSeq . $bitStr); + + $pem = "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($spki), 64, "\n") . "-----END PUBLIC KEY-----\n"; + $key = openssl_pkey_get_public($pem); + if ($key === false) { + throw new TokenException('EdDSA (Ed25519) is not supported by this PHP/OpenSSL version'); + } + return $key; + } + + /** + * Verify a DPoP JWT signature. + * + * EC signatures in JWTs are raw R||S bytes; must convert to DER for OpenSSL. + * RSA-PSS (PS256/384/512) requires special handling. + * + * @param string $signingInput The header.payload string. + * @param string $signature Raw decoded signature bytes. + * @param mixed $publicKey OpenSSL public key resource. + * @param array $jwk JWK array (used to determine key type). + * @param string $alg JWT algorithm string. + * @throws \Exception on verification failure. + */ + private static function verifyDpopSignature( + string $signingInput, + string $signature, + $publicKey, + array $jwk, + string $alg + ): void { + $kty = $jwk['kty'] ?? ''; + + switch ($alg) { + case 'RS256': + $opensslAlg = OPENSSL_ALGO_SHA256; + break; + case 'RS384': + $opensslAlg = OPENSSL_ALGO_SHA384; + break; + case 'RS512': + $opensslAlg = OPENSSL_ALGO_SHA512; + break; + case 'ES256': + $opensslAlg = OPENSSL_ALGO_SHA256; + break; + case 'ES384': + $opensslAlg = OPENSSL_ALGO_SHA384; + break; + case 'ES512': + $opensslAlg = OPENSSL_ALGO_SHA512; + break; + case 'PS256': + case 'PS384': + case 'PS512': + self::verifyPss($signingInput, $signature, $publicKey, $alg); + return; + case 'EdDSA': + self::verifyEdDsa($signingInput, $signature, $publicKey); + return; + default: + throw new TokenException('Unsupported algorithm: ' . $alg); + } + + if ($kty === 'EC') { + // JWTs use raw R||S; OpenSSL needs DER-encoded ECDSA signature + $signature = self::rawEcSigToDer($signature); + } + + $result = openssl_verify($signingInput, $signature, $publicKey, $opensslAlg); + if ($result !== 1) { + throw new TokenException('DPoP proof signature verification failed'); + } + } + + /** + * Convert a raw R||S EC signature (as used in JWTs) to DER format. + * + * @throws \Exception if the signature length is unexpected. + */ + private static function rawEcSigToDer(string $rawSig): string + { + $len = strlen($rawSig); + if ($len % 2 !== 0) { + throw new TokenException('Invalid EC signature length'); + } + $half = $len / 2; + $r = substr($rawSig, 0, $half); + $s = substr($rawSig, $half); + + // Encode as ASN.1 INTEGER (prepend 0x00 if high bit set) + $r = ltrim($r, "\x00"); + if (strlen($r) === 0 || ord($r[0]) > 0x7f) { + $r = "\x00" . $r; + } + $s = ltrim($s, "\x00"); + if (strlen($s) === 0 || ord($s[0]) > 0x7f) { + $s = "\x00" . $s; + } + + $rDer = pack('Ca*a*', 0x02, self::derEncodeLength(strlen($r)), $r); + $sDer = pack('Ca*a*', 0x02, self::derEncodeLength(strlen($s)), $s); + $seq = $rDer . $sDer; + + return pack('Ca*a*', 0x30, self::derEncodeLength(strlen($seq)), $seq); + } + + /** + * Verify RSA-PSS signature (PS256/384/512). + * + * Uses openssl_public_decrypt with PKCS1_PSS padding via raw OpenSSL. + * PHP 7.x doesn't expose RSA-PSS directly in openssl_verify, so we + * use openssl_public_decrypt with OPENSSL_PKCS1_OAEP_PADDING as a + * workaround is NOT correct. Instead, PHP 8.x added support via EVP. + * For broad compatibility we attempt openssl_verify with the PSS digest; + * if not available, throw an informative error. + * + * @throws \Exception on verification failure or unsupported environment. + */ + private static function verifyPss(string $signingInput, string $signature, $publicKey, string $alg): void + { + // Map alg to hash algorithm name + $hashMap = ['PS256' => 'sha256', 'PS384' => 'sha384', 'PS512' => 'sha512']; + $hashAlg = $hashMap[$alg]; + + // Use openssl_public_decrypt with manual PSS verification + // Compute the digest of the signing input + $digest = hash($hashAlg, $signingInput, true); + $digestLen = strlen($digest); + + // Decrypt the signature using the RSA public key (raw RSA operation) + $decrypted = ''; + $decryptResult = openssl_public_decrypt($signature, $decrypted, $publicKey, OPENSSL_NO_PADDING); + if (!$decryptResult) { + throw new TokenException('RSA-PSS signature verification failed (decrypt step)'); + } + + // Verify PSS encoding manually + if (!self::emsaPssVerify($digest, $decrypted, $hashAlg)) { + throw new TokenException('RSA-PSS signature verification failed'); + } + } + + /** + * EMSA-PSS verification per RFC 8017 §9.1.2. + * + * @param string $mHash Hash of the message. + * @param string $em The decoded EM value (same byte length as modulus). + * @param string $hashAlg Hash algorithm name (e.g. 'sha256'). + * @return bool + */ + private static function emsaPssVerify(string $mHash, string $em, string $hashAlg): bool + { + $hLen = strlen($mHash); + $emLen = strlen($em); + $sLen = $hLen; // salt length equals hash length (standard for JWT PS*) + + if ($emLen < $hLen + $sLen + 2) { + return false; + } + + // Last byte must be 0xbc + if (ord($em[$emLen - 1]) !== 0xbc) { + return false; + } + + $maskedDB = substr($em, 0, $emLen - $hLen - 1); + $h = substr($em, $emLen - $hLen - 1, $hLen); + + // Check leftmost bits are zero (emBits = emLen*8 - 1) + if (ord($maskedDB[0]) & 0x80) { + return false; + } + + // Generate DB mask using MGF1 + $dbMask = self::mgf1($h, $emLen - $hLen - 1, $hashAlg); + $db = $maskedDB ^ $dbMask; + + // Clear leftmost bit + $db[0] = chr(ord($db[0]) & 0x7f); + + // Verify padding: emLen - hLen - sLen - 2 zero bytes followed by 0x01 + $padLen = $emLen - $hLen - $sLen - 2; + for ($i = 0; $i < $padLen; $i++) { + if (ord($db[$i]) !== 0x00) { + return false; + } + } + if (ord($db[$padLen]) !== 0x01) { + return false; + } + + $salt = substr($db, $padLen + 1); + + // Construct M' = 0x00 * 8 || mHash || salt + $mPrime = str_repeat("\x00", 8) . $mHash . $salt; + $hPrime = hash($hashAlg, $mPrime, true); + + return hash_equals($h, $hPrime); + } + + /** + * MGF1 mask generation function per RFC 8017. + */ + private static function mgf1(string $seed, int $maskLen, string $hashAlg): string + { + $t = ''; + $hLen = strlen(hash($hashAlg, '', true)); + $ceiling = (int) ceil($maskLen / $hLen); + for ($i = 0; $i < $ceiling; $i++) { + $c = pack('N', $i); + $t .= hash($hashAlg, $seed . $c, true); + } + return substr($t, 0, $maskLen); + } + + /** + * Verify an EdDSA (Ed25519) signature. + * + * @throws \Exception if not supported or verification fails. + */ + private static function verifyEdDsa(string $signingInput, string $signature, $publicKey): void + { + if (!function_exists('openssl_sign') || PHP_VERSION_ID < 80100) { + throw new TokenException('EdDSA requires PHP 8.1+ with OpenSSL 1.1.1+'); + } + $result = openssl_verify($signingInput, $signature, $publicKey, OPENSSL_ALGO_SHA512); + if ($result !== 1) { + throw new TokenException('EdDSA signature verification failed'); + } + } + + /** + * Compute the JWK thumbprint (RFC 7638). + * + * @param array $jwk The JWK array. + * @return string Base64url-encoded SHA-256 thumbprint. + * @throws \Exception on unsupported key type. + */ + private static function computeJwkThumbprint(array $jwk): string + { + $kty = $jwk['kty'] ?? ''; + switch ($kty) { + case 'EC': + $members = [ + 'crv' => $jwk['crv'], + 'kty' => 'EC', + 'x' => $jwk['x'], + 'y' => $jwk['y'], + ]; + break; + case 'RSA': + $members = [ + 'e' => $jwk['e'], + 'kty' => 'RSA', + 'n' => $jwk['n'], + ]; + break; + case 'OKP': + $members = [ + 'crv' => $jwk['crv'], + 'kty' => 'OKP', + 'x' => $jwk['x'], + ]; + break; + default: + throw new TokenException('Unsupported JWK key type for thumbprint: ' . $kty); + } + ksort($members); // alphabetical sort per RFC 7638 + $json = json_encode($members, JSON_UNESCAPED_SLASHES); + return self::base64urlEncode(hash('sha256', $json, true)); + } + + /** + * Check that the DPoP htu claim matches the request URL (RFC 9449 §4.2). + * + * - Strips query and fragment + * - Normalises scheme and host to lowercase + * - Strips default ports (80 for http, 443 for https) + */ + private static function htuMatches(string $htu, string $requestUrl): bool + { + $htuParts = parse_url($htu); + $reqParts = parse_url($requestUrl); + + if (!$htuParts || !$reqParts) { + return false; + } + + foreach (['scheme', 'host'] as $required) { + if (empty($htuParts[$required]) || empty($reqParts[$required])) { + return false; + } + } + + $normalize = function (array $parts): string { + $scheme = strtolower($parts['scheme']); + $host = strtolower($parts['host']); + $port = $parts['port'] ?? null; + $path = $parts['path'] ?? '/'; + + // Strip default ports + if ($port !== null) { + if (($scheme === 'https' && $port == 443) || ($scheme === 'http' && $port == 80)) { + $port = null; + } + } + + $authority = $host . ($port !== null ? ':' . $port : ''); + return $scheme . '://' . $authority . $path; + }; + + return $normalize($htuParts) === $normalize($reqParts); + } + + /** + * DER-encode an ASN.1 length value. + */ + private static function derEncodeLength(int $length): string + { + if ($length < 128) { + return chr($length); + } + $temp = $length; + $bytes = ''; + while ($temp > 0) { + $bytes = chr($temp & 0xFF) . $bytes; + $temp >>= 8; + } + return chr(0x80 | strlen($bytes)) . $bytes; + } +} diff --git a/src/tests/DPoPTest.php b/src/tests/DPoPTest.php new file mode 100644 index 00000000..b11ada39 --- /dev/null +++ b/src/tests/DPoPTest.php @@ -0,0 +1,444 @@ + ['jkt' => 'abc123thumbprint']]; + $this->assertSame('abc123thumbprint', DPoP::getThumbprint($claims)); + } + + public function testGetThumbprintReturnsEmptyWhenAbsent(): void + { + $this->assertSame('', DPoP::getThumbprint([])); + $this->assertSame('', DPoP::getThumbprint(['cnf' => []])); + $this->assertSame('', DPoP::getThumbprint(['sub' => 'user123'])); + } + + // ------------------------------------------------------------------------- + // validateProof: no-op when token is not DPoP-bound + // ------------------------------------------------------------------------- + + public function testValidateProofNoOpWhenNoCnfJkt(): void + { + // Build a minimal session JWT payload with no cnf claim + $payload = base64_url_encode(json_encode(['sub' => 'user1', 'exp' => time() + 3600])); + $fakeJwt = 'header.' . $payload . '.signature'; + + // Should not throw — token is not DPoP-bound + DPoP::validateProof('', 'GET', 'https://example.com/api', $fakeJwt); + $this->assertTrue(true); + } + + // ------------------------------------------------------------------------- + // validateProof: structural / header checks + // ------------------------------------------------------------------------- + + public function testValidateProofThrowsWhenProofTooLong(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/exceeds maximum length/'); + + $sessionJwt = $this->buildSessionJwt(['cnf' => ['jkt' => 'somethumb']]); + DPoP::validateProof(str_repeat('a', 8193), 'GET', 'https://example.com/', $sessionJwt); + } + + public function testValidateProofThrowsWhenProofEmptyAndBound(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/DPoP proof required/'); + + $sessionJwt = $this->buildSessionJwt(['cnf' => ['jkt' => 'somethumb']]); + DPoP::validateProof('', 'GET', 'https://example.com/', $sessionJwt); + } + + public function testValidateProofThrowsOnMalformedJwt(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/malformed DPoP JWT/'); + + $sessionJwt = $this->buildSessionJwt(['cnf' => ['jkt' => 'somethumb']]); + DPoP::validateProof('notajwt', 'GET', 'https://example.com/', $sessionJwt); + } + + public function testValidateProofThrowsOnWrongTyp(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/typ must be dpop\+jwt/'); + + $sessionJwt = $this->buildSessionJwt(['cnf' => ['jkt' => 'somethumb']]); + $proof = $this->buildRawProof(['typ' => 'JWT', 'alg' => 'RS256'], []); + DPoP::validateProof($proof, 'GET', 'https://example.com/', $sessionJwt); + } + + public function testValidateProofThrowsOnRejectedAlg(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/rejected algorithm/'); + + $sessionJwt = $this->buildSessionJwt(['cnf' => ['jkt' => 'somethumb']]); + $proof = $this->buildRawProof(['typ' => 'dpop+jwt', 'alg' => 'HS256'], []); + DPoP::validateProof($proof, 'GET', 'https://example.com/', $sessionJwt); + } + + public function testValidateProofThrowsOnMissingJwk(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/missing jwk header/'); + + $sessionJwt = $this->buildSessionJwt(['cnf' => ['jkt' => 'somethumb']]); + $proof = $this->buildRawProof(['typ' => 'dpop+jwt', 'alg' => 'RS256'], []); + DPoP::validateProof($proof, 'GET', 'https://example.com/', $sessionJwt); + } + + public function testValidateProofThrowsOnSymmetricKey(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/symmetric key not allowed/'); + + $sessionJwt = $this->buildSessionJwt(['cnf' => ['jkt' => 'somethumb']]); + $proof = $this->buildRawProof( + ['typ' => 'dpop+jwt', 'alg' => 'RS256', 'jwk' => ['kty' => 'oct', 'k' => 'abc']], + [] + ); + DPoP::validateProof($proof, 'GET', 'https://example.com/', $sessionJwt); + } + + public function testValidateProofThrowsOnPrivateKeyInJwk(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/must not contain a private key/'); + + $sessionJwt = $this->buildSessionJwt(['cnf' => ['jkt' => 'somethumb']]); + $proof = $this->buildRawProof( + ['typ' => 'dpop+jwt', 'alg' => 'RS256', 'jwk' => ['kty' => 'RSA', 'n' => 'x', 'e' => 'AQAB', 'd' => 'secret']], + [] + ); + DPoP::validateProof($proof, 'GET', 'https://example.com/', $sessionJwt); + } + + // ------------------------------------------------------------------------- + // Full round-trip test with a real RSA key pair + // ------------------------------------------------------------------------- + + public function testValidateProofSucceedsWithValidRsaProof(): void + { + // Generate a fresh RSA key pair for this test + $keyResource = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + $this->assertNotFalse($keyResource, 'Could not generate RSA key pair'); + + $details = openssl_pkey_get_details($keyResource); + $jwk = [ + 'kty' => 'RSA', + 'n' => rtrim(strtr(base64_encode($details['rsa']['n']), '+/', '-_'), '='), + 'e' => rtrim(strtr(base64_encode($details['rsa']['e']), '+/', '-_'), '='), + ]; + + // Compute thumbprint + $members = ['e' => $jwk['e'], 'kty' => 'RSA', 'n' => $jwk['n']]; + ksort($members); + $thumbprint = rtrim(strtr(base64_encode(hash('sha256', json_encode($members, JSON_UNESCAPED_SLASHES), true)), '+/', '-_'), '='); + + // Build session JWT with cnf.jkt + $sessionToken = $this->buildSessionJwt(['sub' => 'user1', 'cnf' => ['jkt' => $thumbprint]]); + + // Build DPoP proof + $method = 'GET'; + $url = 'https://api.example.com/resource'; + $ath = rtrim(strtr(base64_encode(hash('sha256', $sessionToken, true)), '+/', '-_'), '='); + + $header = ['typ' => 'dpop+jwt', 'alg' => 'RS256', 'jwk' => $jwk]; + $payload = [ + 'jti' => bin2hex(random_bytes(16)), + 'htm' => $method, + 'htu' => $url, + 'iat' => time(), + 'ath' => $ath, + ]; + + $headerEnc = rtrim(strtr(base64_encode(json_encode($header)), '+/', '-_'), '='); + $payloadEnc = rtrim(strtr(base64_encode(json_encode($payload)), '+/', '-_'), '='); + $signingInput = $headerEnc . '.' . $payloadEnc; + + openssl_sign($signingInput, $signature, $keyResource, OPENSSL_ALGO_SHA256); + $sigEnc = rtrim(strtr(base64_encode($signature), '+/', '-_'), '='); + + $dpopProof = $signingInput . '.' . $sigEnc; + + // Should not throw + DPoP::validateProof($dpopProof, $method, $url, $sessionToken); + $this->assertTrue(true); + } + + public function testValidateProofSucceedsWithValidEcProof(): void + { + // Generate a fresh EC P-256 key pair + $keyResource = openssl_pkey_new([ + 'curve_name' => 'prime256v1', + 'private_key_type' => OPENSSL_KEYTYPE_EC, + ]); + $this->assertNotFalse($keyResource, 'Could not generate EC key pair'); + + $details = openssl_pkey_get_details($keyResource); + $jwk = [ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => rtrim(strtr(base64_encode(str_pad($details['ec']['x'], 32, "\x00", STR_PAD_LEFT)), '+/', '-_'), '='), + 'y' => rtrim(strtr(base64_encode(str_pad($details['ec']['y'], 32, "\x00", STR_PAD_LEFT)), '+/', '-_'), '='), + ]; + + // Compute thumbprint + $members = ['crv' => $jwk['crv'], 'kty' => 'EC', 'x' => $jwk['x'], 'y' => $jwk['y']]; + ksort($members); + $thumbprint = rtrim(strtr(base64_encode(hash('sha256', json_encode($members, JSON_UNESCAPED_SLASHES), true)), '+/', '-_'), '='); + + $sessionToken = $this->buildSessionJwt(['sub' => 'user1', 'cnf' => ['jkt' => $thumbprint]]); + + $method = 'POST'; + $url = 'https://api.example.com/submit'; + $ath = rtrim(strtr(base64_encode(hash('sha256', $sessionToken, true)), '+/', '-_'), '='); + + $header = ['typ' => 'dpop+jwt', 'alg' => 'ES256', 'jwk' => $jwk]; + $payload = [ + 'jti' => bin2hex(random_bytes(16)), + 'htm' => $method, + 'htu' => $url, + 'iat' => time(), + 'ath' => $ath, + ]; + + $headerEnc = rtrim(strtr(base64_encode(json_encode($header)), '+/', '-_'), '='); + $payloadEnc = rtrim(strtr(base64_encode(json_encode($payload)), '+/', '-_'), '='); + $signingInput = $headerEnc . '.' . $payloadEnc; + + openssl_sign($signingInput, $derSig, $keyResource, OPENSSL_ALGO_SHA256); + + // Convert DER to raw R||S for JWT + $rawSig = $this->derEcSigToRaw($derSig, 32); + $sigEnc = rtrim(strtr(base64_encode($rawSig), '+/', '-_'), '='); + + $dpopProof = $signingInput . '.' . $sigEnc; + + DPoP::validateProof($dpopProof, $method, $url, $sessionToken); + $this->assertTrue(true); + } + + // ------------------------------------------------------------------------- + // Payload claim checks (htm, htu, iat, ath) — tested post-signature so we + // use a real RSA key pair to produce a structurally valid (but claim-invalid) proof. + // ------------------------------------------------------------------------- + + public function testValidateProofThrowsOnHtmMismatch(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/htm mismatch/'); + + [$dpopProof, $sessionToken] = $this->buildSignedProof('POST', 'https://example.com/api'); + DPoP::validateProof($dpopProof, 'GET', 'https://example.com/api', $sessionToken); + } + + public function testValidateProofThrowsOnHtuMismatch(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/htu does not match/'); + + [$dpopProof, $sessionToken] = $this->buildSignedProof('GET', 'https://example.com/api'); + DPoP::validateProof($dpopProof, 'GET', 'https://example.com/other', $sessionToken); + } + + public function testValidateProofThrowsOnExpiredIat(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/iat out of acceptable window/'); + + [$dpopProof, $sessionToken] = $this->buildSignedProof('GET', 'https://example.com/api', time() - 120); + DPoP::validateProof($dpopProof, 'GET', 'https://example.com/api', $sessionToken); + } + + public function testValidateProofThrowsOnFutureIat(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/iat out of acceptable window/'); + + [$dpopProof, $sessionToken] = $this->buildSignedProof('GET', 'https://example.com/api', time() + 60); + DPoP::validateProof($dpopProof, 'GET', 'https://example.com/api', $sessionToken); + } + + public function testValidateProofThrowsOnAthMismatch(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/ath does not match/'); + + // Build proof with a different session token hash + [$dpopProof, $sessionToken] = $this->buildSignedProof('GET', 'https://example.com/api', null, 'wrong-token'); + DPoP::validateProof($dpopProof, 'GET', 'https://example.com/api', $sessionToken); + } + + // ------------------------------------------------------------------------- + // htu URL normalisation edge cases + // ------------------------------------------------------------------------- + + public function testHtuMatchesIgnoresDefaultHttpsPort(): void + { + [$dpopProof, $sessionToken] = $this->buildSignedProof('GET', 'https://example.com:443/api'); + // Should not throw — 443 is default for https + DPoP::validateProof($dpopProof, 'GET', 'https://example.com/api', $sessionToken); + $this->assertTrue(true); + } + + public function testHtuMatchesIgnoresDefaultHttpPort(): void + { + [$dpopProof, $sessionToken] = $this->buildSignedProof('GET', 'http://example.com:80/api'); + DPoP::validateProof($dpopProof, 'GET', 'http://example.com/api', $sessionToken); + $this->assertTrue(true); + } + + public function testHtuMatchesIsCaseInsensitiveForSchemeAndHost(): void + { + [$dpopProof, $sessionToken] = $this->buildSignedProof('GET', 'HTTPS://EXAMPLE.COM/api'); + DPoP::validateProof($dpopProof, 'GET', 'https://example.com/api', $sessionToken); + $this->assertTrue(true); + } + + public function testHtuMismatchOnNonDefaultPort(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/htu does not match/'); + + [$dpopProof, $sessionToken] = $this->buildSignedProof('GET', 'https://example.com:8443/api'); + DPoP::validateProof($dpopProof, 'GET', 'https://example.com/api', $sessionToken); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Build a fake (unsigned) session JWT with given payload claims. + */ + private function buildSessionJwt(array $claims): string + { + $header = base64_url_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); + $payload = base64_url_encode(json_encode(array_merge(['exp' => time() + 3600], $claims))); + return $header . '.' . $payload . '.fakesig'; + } + + /** + * Build a raw (unsigned) DPoP JWT string from separate header/payload arrays. + * Signature is a placeholder. + */ + private function buildRawProof(array $header, array $payload): string + { + $h = base64_url_encode(json_encode($header)); + $p = base64_url_encode(json_encode($payload)); + return $h . '.' . $p . '.fakesig'; + } + + /** + * Build a properly signed RSA DPoP proof + matching session token. + * + * @param string $method + * @param string $url + * @param int|null $iat Override iat timestamp. + * @param string $athToken The token to hash for ath (defaults to session token). + * @return array{string, string} [$dpopProof, $sessionToken] + */ + private function buildSignedProof( + string $method, + string $url, + ?int $iat = null, + ?string $athToken = null + ): array { + $keyResource = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + $details = openssl_pkey_get_details($keyResource); + $jwk = [ + 'kty' => 'RSA', + 'n' => rtrim(strtr(base64_encode($details['rsa']['n']), '+/', '-_'), '='), + 'e' => rtrim(strtr(base64_encode($details['rsa']['e']), '+/', '-_'), '='), + ]; + + $members = ['e' => $jwk['e'], 'kty' => 'RSA', 'n' => $jwk['n']]; + ksort($members); + $thumbprint = rtrim(strtr(base64_encode(hash('sha256', json_encode($members, JSON_UNESCAPED_SLASHES), true)), '+/', '-_'), '='); + + $sessionToken = $this->buildSessionJwt(['sub' => 'u1', 'cnf' => ['jkt' => $thumbprint]]); + $hashSource = $athToken ?? $sessionToken; + $ath = rtrim(strtr(base64_encode(hash('sha256', $hashSource, true)), '+/', '-_'), '='); + + $header = ['typ' => 'dpop+jwt', 'alg' => 'RS256', 'jwk' => $jwk]; + $payload = [ + 'jti' => bin2hex(random_bytes(8)), + 'htm' => strtoupper($method), + 'htu' => $url, + 'iat' => $iat ?? time(), + 'ath' => $ath, + ]; + + $headerEnc = rtrim(strtr(base64_encode(json_encode($header)), '+/', '-_'), '='); + $payloadEnc = rtrim(strtr(base64_encode(json_encode($payload)), '+/', '-_'), '='); + $signingInput = $headerEnc . '.' . $payloadEnc; + + openssl_sign($signingInput, $signature, $keyResource, OPENSSL_ALGO_SHA256); + $sigEnc = rtrim(strtr(base64_encode($signature), '+/', '-_'), '='); + + return [$signingInput . '.' . $sigEnc, $sessionToken]; + } + + /** + * Convert a DER-encoded ECDSA signature to raw R||S bytes. + */ + private function derEcSigToRaw(string $der, int $coordLen): string + { + // DER: SEQUENCE { INTEGER r, INTEGER s } + $pos = 2; // skip SEQUENCE tag + length + if (ord($der[1]) > 0x7f) { + $pos += ord($der[1]) & 0x7f; + } + + $readInt = function (string $der, int &$pos) use ($coordLen): string { + $pos++; // skip INTEGER tag (0x02) + $len = ord($der[$pos++]); + $val = substr($der, $pos, $len); + $pos += $len; + // Strip leading zero if present (added for positive encoding) + if (strlen($val) > $coordLen && ord($val[0]) === 0x00) { + $val = substr($val, 1); + } + return str_pad($val, $coordLen, "\x00", STR_PAD_LEFT); + }; + + $r = $readInt($der, $pos); + $s = $readInt($der, $pos); + return $r . $s; + } +} + +/** + * Standalone base64url-encode helper for tests (avoids depending on DPoP internals). + */ +function base64_url_encode(string $data): string +{ + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); +}