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), '+/', '-_'), '=');
+}