From b6039b395edfb0b873a9c391bb03a636d881ca5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 30 May 2026 09:27:20 +0200 Subject: [PATCH 1/8] Implement OIDC id_token helper --- composer.lock | 460 ++++++++++++++++++++++++---------------- src/Auth/OIDC.php | 300 ++++++++++++++++++++++++++ tests/Auth/OIDCTest.php | 240 +++++++++++++++++++++ 3 files changed, 813 insertions(+), 187 deletions(-) create mode 100644 src/Auth/OIDC.php create mode 100644 tests/Auth/OIDCTest.php diff --git a/composer.lock b/composer.lock index 2b7cfca..fd0b7a7 100644 --- a/composer.lock +++ b/composer.lock @@ -9,16 +9,16 @@ "packages-dev": [ { "name": "amphp/amp", - "version": "v2.6.4", + "version": "v2.6.5", "source": { "type": "git", "url": "https://github.com/amphp/amp.git", - "reference": "ded3d9be08f526089eb7ee8d9f16a9768f9dec2d" + "reference": "d7dda98dae26e56f3f6fcfbf1c1f819c9a993207" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/amp/zipball/ded3d9be08f526089eb7ee8d9f16a9768f9dec2d", - "reference": "ded3d9be08f526089eb7ee8d9f16a9768f9dec2d", + "url": "https://api.github.com/repos/amphp/amp/zipball/d7dda98dae26e56f3f6fcfbf1c1f819c9a993207", + "reference": "d7dda98dae26e56f3f6fcfbf1c1f819c9a993207", "shasum": "" }, "require": { @@ -34,11 +34,6 @@ "vimeo/psalm": "^3.12" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.x-dev" - } - }, "autoload": { "files": [ "lib/functions.php", @@ -86,7 +81,7 @@ "support": { "irc": "irc://irc.freenode.org/amphp", "issues": "https://github.com/amphp/amp/issues", - "source": "https://github.com/amphp/amp/tree/v2.6.4" + "source": "https://github.com/amphp/amp/tree/v2.6.5" }, "funding": [ { @@ -94,7 +89,7 @@ "type": "github" } ], - "time": "2024-03-21T18:52:26+00:00" + "time": "2025-09-03T19:41:28+00:00" }, { "name": "amphp/byte-stream", @@ -238,20 +233,21 @@ "type": "tidelift" } ], + "abandoned": true, "time": "2022-01-17T14:14:24+00:00" }, { "name": "composer/semver", - "version": "3.4.3", + "version": "3.4.4", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12" + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", - "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", "shasum": "" }, "require": { @@ -303,7 +299,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.3" + "source": "https://github.com/composer/semver/tree/3.4.4" }, "funding": [ { @@ -313,13 +309,9 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2024-09-19T14:15:21+00:00" + "time": "2025-08-20T19:15:30+00:00" }, { "name": "composer/xdebug-handler", @@ -424,26 +416,29 @@ }, { "name": "doctrine/deprecations", - "version": "1.1.4", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/31610dbb31faa98e6b5447b62340826f54fbc4e9", - "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12", - "phpstan/phpstan": "1.4.10 || 2.0.3", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -463,36 +458,35 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.4" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2024-12-07T21:18:45+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "doctrine/instantiator", - "version": "2.0.0", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" + "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", - "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/23da848e1a2308728fe5fdddabf4be17ff9720c7", + "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7", "shasum": "" }, "require": { - "php": "^8.1" + "php": "^8.4" }, "require-dev": { - "doctrine/coding-standard": "^11", + "doctrine/coding-standard": "^14", "ext-pdo": "*", "ext-phar": "*", "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^1.9.4", - "phpstan/phpstan-phpunit": "^1.3", - "phpunit/phpunit": "^9.5.27", - "vimeo/psalm": "^5.4" + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5.58" }, "type": "library", "autoload": { @@ -519,7 +513,7 @@ ], "support": { "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/2.0.0" + "source": "https://github.com/doctrine/instantiator/tree/2.1.0" }, "funding": [ { @@ -535,7 +529,7 @@ "type": "tidelift" } ], - "time": "2022-12-30T00:23:10+00:00" + "time": "2026-01-05T06:47:08+00:00" }, { "name": "felixfbecker/advanced-json-rpc", @@ -706,16 +700,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.0", + "version": "1.13.4", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414" + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/024473a478be9df5fdaca2c793f2232fe788e414", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { @@ -754,7 +748,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.0" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { @@ -762,7 +756,7 @@ "type": "tidelift" } ], - "time": "2025-02-12T12:17:51+00:00" + "time": "2025-08-01T08:46:24+00:00" }, { "name": "netresearch/jsonmapper", @@ -817,16 +811,16 @@ }, { "name": "nikic/php-parser", - "version": "v4.19.4", + "version": "v4.19.5", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "715f4d25e225bc47b293a8b997fe6ce99bf987d2" + "reference": "51bd93cc741b7fc3d63d20b6bdcd99fdaa359837" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/715f4d25e225bc47b293a8b997fe6ce99bf987d2", - "reference": "715f4d25e225bc47b293a8b997fe6ce99bf987d2", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/51bd93cc741b7fc3d63d20b6bdcd99fdaa359837", + "reference": "51bd93cc741b7fc3d63d20b6bdcd99fdaa359837", "shasum": "" }, "require": { @@ -841,11 +835,6 @@ "bin/php-parse" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.9-dev" - } - }, "autoload": { "psr-4": { "PhpParser\\": "lib/PhpParser" @@ -867,9 +856,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.19.4" + "source": "https://github.com/nikic/PHP-Parser/tree/v4.19.5" }, - "time": "2024-09-29T15:01:53+00:00" + "time": "2025-12-06T11:45:25+00:00" }, { "name": "openlss/lib-array2xml", @@ -1097,16 +1086,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.1", + "version": "5.6.7", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8" + "reference": "31a105931bc8ffa3a123383829772e832fd8d903" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8", - "reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/31a105931bc8ffa3a123383829772e832fd8d903", + "reference": "31a105931bc8ffa3a123383829772e832fd8d903", "shasum": "" }, "require": { @@ -1116,7 +1105,7 @@ "phpdocumentor/reflection-common": "^2.2", "phpdocumentor/type-resolver": "^1.7", "phpstan/phpdoc-parser": "^1.7|^2.0", - "webmozart/assert": "^1.9.1" + "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { "mockery/mockery": "~1.3.5 || ~1.6.0", @@ -1155,22 +1144,22 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.1" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.7" }, - "time": "2024-12-07T09:39:29+00:00" + "time": "2026-03-18T20:47:46+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.10.0", + "version": "1.12.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a" + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/679e3ce485b99e84c775d28e2e96fade9a7fb50a", - "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", "shasum": "" }, "require": { @@ -1213,22 +1202,22 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.10.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" }, - "time": "2024-11-09T15:12:26+00:00" + "time": "2025-11-21T15:09:14+00:00" }, { "name": "phpstan/phpdoc-parser", - "version": "2.1.0", + "version": "2.3.2", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68" + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", - "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", "shasum": "" }, "require": { @@ -1260,9 +1249,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.1.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" }, - "time": "2025-02-19T13:28:12+00:00" + "time": "2026-01-25T14:56:51+00:00" }, { "name": "phpstan/phpstan", @@ -1644,16 +1633,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.22", + "version": "9.6.34", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "f80235cb4d3caa59ae09be3adf1ded27521d1a9c" + "reference": "b36f02317466907a230d3aa1d34467041271ef4a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f80235cb4d3caa59ae09be3adf1ded27521d1a9c", - "reference": "f80235cb4d3caa59ae09be3adf1ded27521d1a9c", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b36f02317466907a230d3aa1d34467041271ef4a", + "reference": "b36f02317466907a230d3aa1d34467041271ef4a", "shasum": "" }, "require": { @@ -1664,7 +1653,7 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.1", + "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=7.3", @@ -1675,11 +1664,11 @@ "phpunit/php-timer": "^5.0.3", "sebastian/cli-parser": "^1.0.2", "sebastian/code-unit": "^1.0.8", - "sebastian/comparator": "^4.0.8", + "sebastian/comparator": "^4.0.10", "sebastian/diff": "^4.0.6", "sebastian/environment": "^5.1.5", - "sebastian/exporter": "^4.0.6", - "sebastian/global-state": "^5.0.7", + "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", @@ -1727,7 +1716,7 @@ "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.22" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.34" }, "funding": [ { @@ -1738,12 +1727,20 @@ "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" } ], - "time": "2024-12-05T13:48:26+00:00" + "time": "2026-01-27T05:45:00+00:00" }, { "name": "psr/container", @@ -2017,16 +2014,16 @@ }, { "name": "sebastian/comparator", - "version": "4.0.8", + "version": "4.0.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a" + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", "shasum": "" }, "require": { @@ -2079,15 +2076,27 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8" + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" }, "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/comparator", + "type": "tidelift" } ], - "time": "2022-09-14T12:41:17+00:00" + "time": "2026-01-24T09:22:56+00:00" }, { "name": "sebastian/complexity", @@ -2277,16 +2286,16 @@ }, { "name": "sebastian/exporter", - "version": "4.0.6", + "version": "4.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72" + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c", "shasum": "" }, "require": { @@ -2342,28 +2351,40 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6" + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8" }, "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/exporter", + "type": "tidelift" } ], - "time": "2024-03-02T06:33:00+00:00" + "time": "2025-09-24T06:03:27+00:00" }, { "name": "sebastian/global-state", - "version": "5.0.7", + "version": "5.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9" + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", "shasum": "" }, "require": { @@ -2406,15 +2427,27 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.7" + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8" }, "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/global-state", + "type": "tidelift" } ], - "time": "2024-03-02T06:35:11+00:00" + "time": "2025-08-10T07:10:35+00:00" }, { "name": "sebastian/lines-of-code", @@ -2587,16 +2620,16 @@ }, { "name": "sebastian/recursion-context", - "version": "4.0.5", + "version": "4.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1" + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0", "shasum": "" }, "require": { @@ -2638,15 +2671,27 @@ "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.5" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.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/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2023-02-03T06:07:39+00:00" + "time": "2025-08-10T06:57:39+00:00" }, { "name": "sebastian/resource-operations", @@ -2912,16 +2957,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.5.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6" + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { @@ -2934,7 +2979,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -2959,7 +3004,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { @@ -2970,25 +3015,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.31.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -3038,7 +3087,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -3049,25 +3098,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.31.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", "shasum": "" }, "require": { @@ -3116,7 +3169,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" }, "funding": [ { @@ -3127,25 +3180,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-26T05:58:03+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.31.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { @@ -3197,7 +3254,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -3208,28 +3265,33 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.31.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", "shasum": "" }, "require": { + "ext-iconv": "*", "php": ">=7.2" }, "provide": { @@ -3277,7 +3339,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" }, "funding": [ { @@ -3288,16 +3350,20 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php73", - "version": "v1.31.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php73.git", @@ -3353,7 +3419,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php73/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php73/tree/v1.37.0" }, "funding": [ { @@ -3364,6 +3430,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -3373,16 +3443,16 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.31.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { @@ -3433,7 +3503,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -3444,25 +3514,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.5.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0" + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "shasum": "" }, "require": { @@ -3480,7 +3554,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -3516,7 +3590,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" }, "funding": [ { @@ -3527,25 +3601,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2026-03-28T09:44:51+00:00" }, { "name": "symfony/string", - "version": "v6.4.15", + "version": "v6.4.39", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f" + "reference": "62e3c927de664edadb5bef260987eb047a17a113" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f", - "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f", + "url": "https://api.github.com/repos/symfony/string/zipball/62e3c927de664edadb5bef260987eb047a17a113", + "reference": "62e3c927de664edadb5bef260987eb047a17a113", "shasum": "" }, "require": { @@ -3559,7 +3637,6 @@ "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/error-handler": "^5.4|^6.0|^7.0", "symfony/http-client": "^5.4|^6.0|^7.0", "symfony/intl": "^6.2|^7.0", "symfony/translation-contracts": "^2.5|^3.0", @@ -3602,7 +3679,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.15" + "source": "https://github.com/symfony/string/tree/v6.4.39" }, "funding": [ { @@ -3613,25 +3690,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-13T13:31:12+00:00" + "time": "2026-05-12T11:44:19+00:00" }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { @@ -3660,7 +3741,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.2.3" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -3668,7 +3749,7 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-11-17T20:03:58+00:00" }, { "name": "vimeo/psalm", @@ -3777,30 +3858,35 @@ }, { "name": "webmozart/assert", - "version": "1.9.1", + "version": "1.12.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "bafc69caeb4d49c39fd0779086c03a3738cbb389" + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/bafc69caeb4d49c39fd0779086c03a3738cbb389", - "reference": "bafc69caeb4d49c39fd0779086c03a3738cbb389", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", "shasum": "" }, "require": { - "php": "^5.3.3 || ^7.0 || ^8.0", - "symfony/polyfill-ctype": "^1.8" - }, - "conflict": { - "phpstan/phpstan": "<0.12.20", - "vimeo/psalm": "<3.9.1" + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^7.2 || ^8.0" }, - "require-dev": { - "phpunit/phpunit": "^4.8.36 || ^7.5.13" + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.10-dev" + } + }, "autoload": { "psr-4": { "Webmozart\\Assert\\": "src/" @@ -3824,9 +3910,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.9.1" + "source": "https://github.com/webmozarts/assert/tree/1.12.1" }, - "time": "2020-07-08T17:02:28+00:00" + "time": "2025-10-29T15:56:20+00:00" }, { "name": "webmozart/glob", @@ -3942,6 +4028,6 @@ "ext-scrypt": "*", "ext-sodium": "*" }, - "platform-dev": [], - "plugin-api-version": "2.6.0" + "platform-dev": {}, + "plugin-api-version": "2.9.0" } diff --git a/src/Auth/OIDC.php b/src/Auth/OIDC.php new file mode 100644 index 0000000..b5e25cb --- /dev/null +++ b/src/Auth/OIDC.php @@ -0,0 +1,300 @@ +". + */ + protected string $issuer; + + /** + * The JWS "kid" header. When null it is derived from the public key. + */ + protected ?string $keyId; + + /** + * @param string $privateKey PEM-encoded RSA private key, generate using {@see generateKeyPair()}. + * @param string $publicKey PEM-encoded RSA public key, generate using {@see generatePublicKey()}. + * @param string $issuer The "iss" claim value. + * @param string|null $keyId Optional "kid" header; derived from the public key when null. + * + * @throws \Exception When a key cannot be parsed. + */ + public function __construct( + string $privateKey, + string $publicKey, + string $issuer, + ?string $keyId = null, + ) { + if (empty($privateKey) || empty($publicKey)) { + throw new \Exception('Both a private and a public key are required'); + } + + if (empty($issuer)) { + throw new \Exception('An issuer is required'); + } + + $this->privateKey = $privateKey; + $this->publicKey = $publicKey; + $this->issuer = $issuer; + $this->keyId = $keyId; + } + + /** + * Generate a fresh RSA keypair suitable for signing id_tokens with RS256. + * + * Returns a tuple of PEM-encoded keys that can be passed straight to the + * constructor: [$privateKey, $publicKey]. + * + * @return array{0: string, 1: string} + * + * @throws \Exception When key generation fails. + */ + public static function generateKeyPair(int $bits = 2048): array + { + $resource = \openssl_pkey_new([ + 'private_key_bits' => $bits, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + if ($resource === false) { + throw new \Exception('Unable to generate an RSA key pair'); + } + + return [ + self::generatePrivateKey($resource), + self::generatePublicKey($resource), + ]; + } + + /** + * Export the PEM-encoded private key from an OpenSSL key resource, or + * generate a fresh keypair and return its private key when none is given. + * + * @param \OpenSSLAsymmetricKey|null $resource An existing key resource, or null to create one. + * + * @throws \Exception When the key cannot be exported. + */ + private static function generatePrivateKey(?\OpenSSLAsymmetricKey $resource = null, int $bits = 2048): string + { + $resource ??= \openssl_pkey_new([ + 'private_key_bits' => $bits, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + if ($resource === false) { + throw new \Exception('Unable to generate an RSA key pair'); + } + + $privateKey = ''; + if (!\openssl_pkey_export($resource, $privateKey)) { + throw new \Exception('Unable to export the private key'); + } + + return $privateKey; + } + + /** + * Export the PEM-encoded public key, either from an existing OpenSSL key + * resource or from a PEM-encoded private key string. + * + * @param \OpenSSLAsymmetricKey|string $key A key resource or a PEM-encoded private key. + * + * @throws \Exception When the public key cannot be derived. + */ + private static function generatePublicKey(\OpenSSLAsymmetricKey|string $key): string + { + if (\is_string($key)) { + $resource = \openssl_pkey_get_private($key); + if ($resource === false) { + throw new \Exception('Unable to parse the private key'); + } + } else { + $resource = $key; + } + + $details = \openssl_pkey_get_details($resource); + if ($details === false || !isset($details['key'])) { + throw new \Exception('Unable to export the public key'); + } + + return $details['key']; + } + + /** + * Build a signed OIDC id_token (OpenID Connect Core 1.0 §2). + * + * Pass $accessToken when an access_token is co-issued in the same + * response (OIDC §3.1.3.6 — adds at_hash). Pass $code when an authorization + * code is co-issued in the same response (OIDC §3.3.2.11, Hybrid Flow — + * adds c_hash). Either, neither, or both may be set. + * + * Signs with RS256 using the configured RSA private key. + * + * @param string $subject The "sub" claim (the authenticated user). + * @param string $audience The "aud" claim (the client the token is for). + * @param int $authTime Time the end-user authenticated ("auth_time"), as a Unix timestamp. + * @param int $duration Lifetime of the token in seconds (used for "exp"). + * @param string|null $nonce The "nonce" value sent in the authentication request. + * @param string|null $accessToken Co-issued access_token; adds "at_hash" when set. + * @param string|null $code Co-issued authorization code; adds "c_hash" when set. + * @param array $claims Additional claims to merge into the payload. + * + * @throws \Exception When signing fails. + */ + public function issue( + string $subject, + string $audience, + int $authTime, + int $duration, + ?string $nonce = null, + ?string $accessToken = null, + ?string $code = null, + array $claims = [], + ): string { + $now = \time(); + + $claims = \array_merge($claims, [ + 'iss' => $this->issuer, + 'sub' => $subject, + 'aud' => $audience, + 'exp' => $now + $duration, + 'iat' => $now, + 'auth_time' => $authTime, + ]); + + if (!empty($nonce)) { + $claims['nonce'] = $nonce; + } + + if (!empty($accessToken)) { + $claims['at_hash'] = $this->leftHalfHash($accessToken); + } + + if (!empty($code)) { + $claims['c_hash'] = $this->leftHalfHash($code); + } + + $header = [ + 'typ' => 'JWT', + 'alg' => 'RS256', + 'kid' => $this->getKeyId(), + ]; + + $signingInput = $this->base64UrlEncode((string) \json_encode($header)) + . '.' + . $this->base64UrlEncode((string) \json_encode($claims)); + + $privateKey = \openssl_pkey_get_private($this->privateKey); + if ($privateKey === false) { + throw new \Exception('Unable to parse the private key'); + } + + $signature = ''; + if (!\openssl_sign($signingInput, $signature, $privateKey, OPENSSL_ALGO_SHA256)) { + throw new \Exception('Unable to sign the id_token'); + } + + return $signingInput . '.' . $this->base64UrlEncode($signature); + } + + /** + * Get the JWS "kid" header. When none was supplied it is derived + * deterministically from the public key's RSA modulus, so the same key + * always yields the same id. + * + * @throws \Exception When the public key cannot be parsed. + */ + public function getKeyId(): string + { + if ($this->keyId !== null) { + return $this->keyId; + } + + $this->keyId = \hash('sha256', $this->getModulus()); + + return $this->keyId; + } + + /** + * Build the public key as a JWK (RFC 7517) suitable for publishing on a + * JWKS endpoint so clients can verify the issued id_tokens. + * + * @return array + * + * @throws \Exception When the public key cannot be parsed. + */ + public function getPublicJwk(): array + { + $publicKey = \openssl_pkey_get_public($this->publicKey); + if ($publicKey === false) { + throw new \Exception('Unable to parse the public key'); + } + + $details = \openssl_pkey_get_details($publicKey); + if ($details === false || !isset($details['rsa'])) { + throw new \Exception('Public key is not an RSA key'); + } + + return [ + 'kty' => 'RSA', + 'use' => 'sig', + 'alg' => 'RS256', + 'kid' => $this->getKeyId(), + 'n' => $this->base64UrlEncode($details['rsa']['n']), + 'e' => $this->base64UrlEncode($details['rsa']['e']), + ]; + } + + /** + * Read the raw RSA modulus (the "n" parameter) from the public key. + * + * @throws \Exception When the public key cannot be parsed. + */ + protected function getModulus(): string + { + $publicKey = \openssl_pkey_get_public($this->publicKey); + if ($publicKey === false) { + throw new \Exception('Unable to parse the public key'); + } + + $details = \openssl_pkey_get_details($publicKey); + if ($details === false || !isset($details['rsa']['n'])) { + throw new \Exception('Public key is not an RSA key'); + } + + return $details['rsa']['n']; + } + + /** + * OIDC §3.1.3.6 / §3.3.2.11: hash with the same algorithm family as the + * id_token signature (SHA-256 for RS256), take the left-most half + * (16 bytes / 128 bits), base64url-encode without padding. + */ + protected function leftHalfHash(string $value): string + { + return $this->base64UrlEncode(\substr(\hash('sha256', $value, true), 0, 16)); + } + + /** + * Base64url-encode without padding (RFC 7515 §2). + */ + protected function base64UrlEncode(string $value): string + { + return \rtrim(\strtr(\base64_encode($value), '+/', '-_'), '='); + } +} diff --git a/tests/Auth/OIDCTest.php b/tests/Auth/OIDCTest.php new file mode 100644 index 0000000..1197e22 --- /dev/null +++ b/tests/Auth/OIDCTest.php @@ -0,0 +1,240 @@ +privateKey, $this->publicKey] = OIDC::generateKeyPair(); + + $this->oidc = new OIDC( + $this->privateKey, + $this->publicKey, + 'https://example.com/v1/oauth2/test', + ); + } + + /** + * Decode a JWT segment from base64url JSON into an array. + * + * @return array + */ + private function decodeSegment(string $segment): array + { + $json = \base64_decode(\strtr($segment, '-_', '+/')); + + return \json_decode($json, true); + } + + public function testIssueStructure(): void + { + $token = $this->oidc->issue('user-123', 'client-abc', 1000, 3600); + + $parts = \explode('.', $token); + $this->assertCount(3, $parts); + + $header = $this->decodeSegment($parts[0]); + $this->assertEquals('JWT', $header['typ']); + $this->assertEquals('RS256', $header['alg']); + $this->assertEquals($this->oidc->getKeyId(), $header['kid']); + } + + public function testIssueClaims(): void + { + $before = \time(); + $token = $this->oidc->issue('user-123', 'client-abc', 1000, 3600); + $after = \time(); + + $claims = $this->decodeSegment(\explode('.', $token)[1]); + + $this->assertEquals('https://example.com/v1/oauth2/test', $claims['iss']); + $this->assertEquals('user-123', $claims['sub']); + $this->assertEquals('client-abc', $claims['aud']); + $this->assertEquals(1000, $claims['auth_time']); + $this->assertGreaterThanOrEqual($before, $claims['iat']); + $this->assertLessThanOrEqual($after, $claims['iat']); + $this->assertEquals($claims['iat'] + 3600, $claims['exp']); + + // Optional claims absent by default + $this->assertArrayNotHasKey('nonce', $claims); + $this->assertArrayNotHasKey('at_hash', $claims); + $this->assertArrayNotHasKey('c_hash', $claims); + } + + public function testSignatureIsValid(): void + { + $token = $this->oidc->issue('user-123', 'client-abc', 1000, 3600); + + $parts = \explode('.', $token); + $signingInput = $parts[0] . '.' . $parts[1]; + $signature = \base64_decode(\strtr($parts[2], '-_', '+/')); + + $result = \openssl_verify( + $signingInput, + $signature, + $this->publicKey, + OPENSSL_ALGO_SHA256 + ); + + $this->assertEquals(1, $result); + } + + public function testNonceClaim(): void + { + $token = $this->oidc->issue('user-123', 'client-abc', 1000, 3600, 'n-0S6_WzA2Mj'); + $claims = $this->decodeSegment(\explode('.', $token)[1]); + + $this->assertEquals('n-0S6_WzA2Mj', $claims['nonce']); + } + + public function testAtHashAndCHash(): void + { + $accessToken = 'access-token-value'; + $code = 'authorization-code-value'; + + $token = $this->oidc->issue('user-123', 'client-abc', 1000, 3600, null, $accessToken, $code); + $claims = $this->decodeSegment(\explode('.', $token)[1]); + + $expectedAtHash = $this->expectedLeftHalfHash($accessToken); + $expectedCHash = $this->expectedLeftHalfHash($code); + + $this->assertEquals($expectedAtHash, $claims['at_hash']); + $this->assertEquals($expectedCHash, $claims['c_hash']); + } + + public function testAdditionalClaims(): void + { + $token = $this->oidc->issue('user-123', 'client-abc', 1000, 3600, null, null, null, [ + 'email' => 'user@example.com', + 'email_verified' => true, + ]); + $claims = $this->decodeSegment(\explode('.', $token)[1]); + + $this->assertEquals('user@example.com', $claims['email']); + $this->assertTrue($claims['email_verified']); + } + + public function testAdditionalClaimsCannotOverrideRegisteredClaims(): void + { + $token = $this->oidc->issue('user-123', 'client-abc', 1000, 3600, null, null, null, [ + 'sub' => 'attacker', + 'iss' => 'https://evil.example.com', + ]); + $claims = $this->decodeSegment(\explode('.', $token)[1]); + + $this->assertEquals('user-123', $claims['sub']); + $this->assertEquals('https://example.com/v1/oauth2/test', $claims['iss']); + } + + public function testKeyIdIsDeterministic(): void + { + $other = new OIDC($this->privateKey, $this->publicKey, 'https://example.com/v1/oauth2/test'); + + $this->assertEquals($this->oidc->getKeyId(), $other->getKeyId()); + $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $this->oidc->getKeyId()); + } + + public function testCustomKeyId(): void + { + $oidc = new OIDC($this->privateKey, $this->publicKey, 'https://example.com', 'my-custom-kid'); + + $this->assertEquals('my-custom-kid', $oidc->getKeyId()); + + $token = $oidc->issue('user-123', 'client-abc', 1000, 3600); + $header = $this->decodeSegment(\explode('.', $token)[0]); + $this->assertEquals('my-custom-kid', $header['kid']); + } + + public function testGetPublicJwk(): void + { + $jwk = $this->oidc->getPublicJwk(); + + $this->assertEquals('RSA', $jwk['kty']); + $this->assertEquals('sig', $jwk['use']); + $this->assertEquals('RS256', $jwk['alg']); + $this->assertEquals($this->oidc->getKeyId(), $jwk['kid']); + $this->assertNotEmpty($jwk['n']); + $this->assertNotEmpty($jwk['e']); + // base64url: no padding, no +/ characters + $this->assertStringNotContainsString('=', $jwk['n']); + $this->assertStringNotContainsString('+', $jwk['n']); + $this->assertStringNotContainsString('/', $jwk['n']); + } + + public function testEmptyPrivateKeyThrows(): void + { + $this->expectException(\Exception::class); + new OIDC('', $this->publicKey, 'https://example.com'); + } + + public function testEmptyPublicKeyThrows(): void + { + $this->expectException(\Exception::class); + new OIDC($this->privateKey, '', 'https://example.com'); + } + + public function testEmptyIssuerThrows(): void + { + $this->expectException(\Exception::class); + new OIDC($this->privateKey, $this->publicKey, ''); + } + + public function testGenerateKeyPair(): void + { + [$privateKey, $publicKey] = OIDC::generateKeyPair(); + + $this->assertStringContainsString('PRIVATE KEY', $privateKey); + $this->assertStringContainsString('PUBLIC KEY', $publicKey); + + // The generated keys are usable for issuing and verifying a token. + $oidc = new OIDC($privateKey, $publicKey, 'https://example.com'); + $token = $oidc->issue('user-123', 'client-abc', 1000, 3600); + + $parts = \explode('.', $token); + $result = \openssl_verify( + $parts[0] . '.' . $parts[1], + \base64_decode(\strtr($parts[2], '-_', '+/')), + $publicKey, + OPENSSL_ALGO_SHA256 + ); + $this->assertEquals(1, $result); + } + + public function testGeneratePublicKeyFromPrivateKey(): void + { + $privateKey = OIDC::generatePrivateKey(); + $publicKey = OIDC::generatePublicKey($privateKey); + + $this->assertStringContainsString('PUBLIC KEY', $publicKey); + + // The derived public key matches the private key it was derived from. + $oidc = new OIDC($privateKey, $publicKey, 'https://example.com'); + $token = $oidc->issue('user-123', 'client-abc', 1000, 3600); + $parts = \explode('.', $token); + $result = \openssl_verify( + $parts[0] . '.' . $parts[1], + \base64_decode(\strtr($parts[2], '-_', '+/')), + $publicKey, + OPENSSL_ALGO_SHA256 + ); + $this->assertEquals(1, $result); + } + + /** + * Mirror of OIDC::leftHalfHash for assertion purposes. + */ + private function expectedLeftHalfHash(string $value): string + { + return \rtrim(\strtr(\base64_encode(\substr(\hash('sha256', $value, true), 0, 16)), '+/', '-_'), '='); + } +} From 839b8b8f12ea00613a3d62c6d40d8b7bd646b4c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 30 May 2026 09:54:11 +0200 Subject: [PATCH 2/8] Finish issuers implementation --- src/Auth/Issuer.php | 105 +++++++++++ src/Auth/{OIDC.php => Issuers/Asymmetric.php} | 167 ++++++------------ src/Auth/Issuers/Asymmetric/AccessToken.php | 76 ++++++++ src/Auth/Issuers/Asymmetric/IdToken.php | 90 ++++++++++ src/Auth/Issuers/Symmetric.php | 85 +++++++++ src/Auth/Issuers/Symmetric/RefreshToken.php | 70 ++++++++ .../Issuers/Asymmetric/AccessTokenTest.php | 128 ++++++++++++++ .../Asymmetric/IdTokenTest.php} | 78 +++----- .../Issuers/Symmetric/RefreshTokenTest.php | 154 ++++++++++++++++ 9 files changed, 790 insertions(+), 163 deletions(-) create mode 100644 src/Auth/Issuer.php rename src/Auth/{OIDC.php => Issuers/Asymmetric.php} (62%) create mode 100644 src/Auth/Issuers/Asymmetric/AccessToken.php create mode 100644 src/Auth/Issuers/Asymmetric/IdToken.php create mode 100644 src/Auth/Issuers/Symmetric.php create mode 100644 src/Auth/Issuers/Symmetric/RefreshToken.php create mode 100644 tests/Auth/Issuers/Asymmetric/AccessTokenTest.php rename tests/Auth/{OIDCTest.php => Issuers/Asymmetric/IdTokenTest.php} (67%) create mode 100644 tests/Auth/Issuers/Symmetric/RefreshTokenTest.php diff --git a/src/Auth/Issuer.php b/src/Auth/Issuer.php new file mode 100644 index 0000000..b069668 --- /dev/null +++ b/src/Auth/Issuer.php @@ -0,0 +1,105 @@ +". + */ + protected string $issuer; + + /** + * @param string $issuer The "iss" claim value. + * + * @throws \Exception When the issuer is missing. + */ + public function __construct(string $issuer) + { + if (empty($issuer)) { + throw new \Exception('An issuer is required'); + } + + $this->issuer = $issuer; + } + + /** + * The JWS "typ" header value for tokens produced by this issuer + * (e.g. "JWT" for an OIDC id_token, "at+jwt" for an RFC 9068 access token). + */ + abstract protected function getType(): string; + + /** + * The JWS "alg" header value (e.g. "RS256", "HS256"). + */ + abstract protected function getAlgorithm(): string; + + /** + * Produce the raw (binary) signature for the given signing input. + */ + abstract protected function signInput(string $signingInput): string; + + /** + * Extra header fields to merge in on top of "typ" and "alg" + * (e.g. a "kid"). Empty by default. + * + * @return array + */ + protected function getHeaders(): array + { + return []; + } + + /** + * Encode a set of claims into a signed compact JWS. The header is built + * from {@see getType()}, {@see getAlgorithm()} and {@see getHeaders()}; + * the signature is delegated to {@see signInput()}. + * + * @param array $claims + * + * @throws \Exception When signing fails. + */ + protected function sign(array $claims): string + { + $header = \array_merge([ + 'typ' => $this->getType(), + 'alg' => $this->getAlgorithm(), + ], $this->getHeaders()); + + $signingInput = $this->base64UrlEncode((string) \json_encode($header)) + . '.' + . $this->base64UrlEncode((string) \json_encode($claims)); + + return $signingInput . '.' . $this->base64UrlEncode($this->signInput($signingInput)); + } + + /** + * Generate a unique token identifier suitable for the "jti" claim + * (RFC 7519 §4.1.7) as a random hex string. + * + * @throws \Exception When sufficient randomness is unavailable. + */ + protected function generateJti(int $bytes = 16): string + { + return \bin2hex(\random_bytes($bytes)); + } + + /** + * Base64url-encode without padding (RFC 7515 §2). + */ + protected function base64UrlEncode(string $value): string + { + return \rtrim(\strtr(\base64_encode($value), '+/', '-_'), '='); + } +} diff --git a/src/Auth/OIDC.php b/src/Auth/Issuers/Asymmetric.php similarity index 62% rename from src/Auth/OIDC.php rename to src/Auth/Issuers/Asymmetric.php index b5e25cb..bc4293b 100644 --- a/src/Auth/OIDC.php +++ b/src/Auth/Issuers/Asymmetric.php @@ -1,11 +1,21 @@ ". - */ - protected string $issuer; - /** * The JWS "kid" header. When null it is derived from the public key. */ @@ -28,11 +32,11 @@ class OIDC /** * @param string $privateKey PEM-encoded RSA private key, generate using {@see generateKeyPair()}. - * @param string $publicKey PEM-encoded RSA public key, generate using {@see generatePublicKey()}. + * @param string $publicKey PEM-encoded RSA public key, generate using {@see generateKeyPair()}. * @param string $issuer The "iss" claim value. * @param string|null $keyId Optional "kid" header; derived from the public key when null. * - * @throws \Exception When a key cannot be parsed. + * @throws \Exception When a key or the issuer is missing. */ public function __construct( string $privateKey, @@ -40,22 +44,19 @@ public function __construct( string $issuer, ?string $keyId = null, ) { + parent::__construct($issuer); + if (empty($privateKey) || empty($publicKey)) { throw new \Exception('Both a private and a public key are required'); } - if (empty($issuer)) { - throw new \Exception('An issuer is required'); - } - $this->privateKey = $privateKey; $this->publicKey = $publicKey; - $this->issuer = $issuer; $this->keyId = $keyId; } /** - * Generate a fresh RSA keypair suitable for signing id_tokens with RS256. + * Generate a fresh RSA keypair suitable for signing tokens with RS256. * * Returns a tuple of PEM-encoded keys that can be passed straight to the * constructor: [$privateKey, $publicKey]. @@ -135,83 +136,6 @@ private static function generatePublicKey(\OpenSSLAsymmetricKey|string $key): st return $details['key']; } - /** - * Build a signed OIDC id_token (OpenID Connect Core 1.0 §2). - * - * Pass $accessToken when an access_token is co-issued in the same - * response (OIDC §3.1.3.6 — adds at_hash). Pass $code when an authorization - * code is co-issued in the same response (OIDC §3.3.2.11, Hybrid Flow — - * adds c_hash). Either, neither, or both may be set. - * - * Signs with RS256 using the configured RSA private key. - * - * @param string $subject The "sub" claim (the authenticated user). - * @param string $audience The "aud" claim (the client the token is for). - * @param int $authTime Time the end-user authenticated ("auth_time"), as a Unix timestamp. - * @param int $duration Lifetime of the token in seconds (used for "exp"). - * @param string|null $nonce The "nonce" value sent in the authentication request. - * @param string|null $accessToken Co-issued access_token; adds "at_hash" when set. - * @param string|null $code Co-issued authorization code; adds "c_hash" when set. - * @param array $claims Additional claims to merge into the payload. - * - * @throws \Exception When signing fails. - */ - public function issue( - string $subject, - string $audience, - int $authTime, - int $duration, - ?string $nonce = null, - ?string $accessToken = null, - ?string $code = null, - array $claims = [], - ): string { - $now = \time(); - - $claims = \array_merge($claims, [ - 'iss' => $this->issuer, - 'sub' => $subject, - 'aud' => $audience, - 'exp' => $now + $duration, - 'iat' => $now, - 'auth_time' => $authTime, - ]); - - if (!empty($nonce)) { - $claims['nonce'] = $nonce; - } - - if (!empty($accessToken)) { - $claims['at_hash'] = $this->leftHalfHash($accessToken); - } - - if (!empty($code)) { - $claims['c_hash'] = $this->leftHalfHash($code); - } - - $header = [ - 'typ' => 'JWT', - 'alg' => 'RS256', - 'kid' => $this->getKeyId(), - ]; - - $signingInput = $this->base64UrlEncode((string) \json_encode($header)) - . '.' - . $this->base64UrlEncode((string) \json_encode($claims)); - - $privateKey = \openssl_pkey_get_private($this->privateKey); - if ($privateKey === false) { - throw new \Exception('Unable to parse the private key'); - } - - $signature = ''; - if (!\openssl_sign($signingInput, $signature, $privateKey, OPENSSL_ALGO_SHA256)) { - throw new \Exception('Unable to sign the id_token'); - } - - return $signingInput . '.' . $this->base64UrlEncode($signature); - } - /** * Get the JWS "kid" header. When none was supplied it is derived * deterministically from the public key's RSA modulus, so the same key @@ -232,7 +156,7 @@ public function getKeyId(): string /** * Build the public key as a JWK (RFC 7517) suitable for publishing on a - * JWKS endpoint so clients can verify the issued id_tokens. + * JWKS endpoint so clients can verify the issued tokens. * * @return array * @@ -260,6 +184,39 @@ public function getPublicJwk(): array ]; } + protected function getAlgorithm(): string + { + return 'RS256'; + } + + /** + * @return array + * + * @throws \Exception When the public key cannot be parsed. + */ + protected function getHeaders(): array + { + return ['kid' => $this->getKeyId()]; + } + + /** + * @throws \Exception When the private key cannot be parsed or signing fails. + */ + protected function signInput(string $signingInput): string + { + $privateKey = \openssl_pkey_get_private($this->privateKey); + if ($privateKey === false) { + throw new \Exception('Unable to parse the private key'); + } + + $signature = ''; + if (!\openssl_sign($signingInput, $signature, $privateKey, OPENSSL_ALGO_SHA256)) { + throw new \Exception('Unable to sign the token'); + } + + return $signature; + } + /** * Read the raw RSA modulus (the "n" parameter) from the public key. * @@ -279,22 +236,4 @@ protected function getModulus(): string return $details['rsa']['n']; } - - /** - * OIDC §3.1.3.6 / §3.3.2.11: hash with the same algorithm family as the - * id_token signature (SHA-256 for RS256), take the left-most half - * (16 bytes / 128 bits), base64url-encode without padding. - */ - protected function leftHalfHash(string $value): string - { - return $this->base64UrlEncode(\substr(\hash('sha256', $value, true), 0, 16)); - } - - /** - * Base64url-encode without padding (RFC 7515 §2). - */ - protected function base64UrlEncode(string $value): string - { - return \rtrim(\strtr(\base64_encode($value), '+/', '-_'), '='); - } } diff --git a/src/Auth/Issuers/Asymmetric/AccessToken.php b/src/Auth/Issuers/Asymmetric/AccessToken.php new file mode 100644 index 0000000..01e8216 --- /dev/null +++ b/src/Auth/Issuers/Asymmetric/AccessToken.php @@ -0,0 +1,76 @@ + $scopes Granted scopes; joined into the space-delimited "scope" claim when non-empty. + * @param string|null $jti The "jti" claim; a random identifier is generated when null. + * @param array $claims Additional claims to merge into the payload. + * + * @throws \Exception When signing fails. + */ + public function issue( + string $subject, + string $audience, + string $clientId, + int $authTime, + int $duration, + array $scopes = [], + ?string $jti = null, + array $claims = [], + ): string { + $now = \time(); + + $claims = \array_merge($claims, [ + 'iss' => $this->issuer, + 'aud' => $audience, + 'sub' => $subject, + 'client_id' => $clientId, + 'exp' => $now + $duration, + 'iat' => $now, + 'jti' => $jti ?? $this->generateJti(), + 'auth_time' => $authTime, + ]); + + if (!empty($scopes)) { + $claims['scope'] = \implode(' ', $scopes); + } + + return $this->sign($claims); + } +} diff --git a/src/Auth/Issuers/Asymmetric/IdToken.php b/src/Auth/Issuers/Asymmetric/IdToken.php new file mode 100644 index 0000000..a400a39 --- /dev/null +++ b/src/Auth/Issuers/Asymmetric/IdToken.php @@ -0,0 +1,90 @@ + $claims Additional claims to merge into the payload. + * + * @throws \Exception When signing fails. + */ + public function issue( + string $subject, + string $audience, + int $authTime, + int $duration, + ?string $nonce = null, + ?string $accessToken = null, + ?string $code = null, + array $claims = [], + ): string { + $now = \time(); + + $claims = \array_merge($claims, [ + 'iss' => $this->issuer, + 'sub' => $subject, + 'aud' => $audience, + 'exp' => $now + $duration, + 'iat' => $now, + 'auth_time' => $authTime, + ]); + + if (!empty($nonce)) { + $claims['nonce'] = $nonce; + } + + if (!empty($accessToken)) { + $claims['at_hash'] = $this->leftHalfHash($accessToken); + } + + if (!empty($code)) { + $claims['c_hash'] = $this->leftHalfHash($code); + } + + return $this->sign($claims); + } + + /** + * OIDC §3.1.3.6 / §3.3.2.11: hash with the same algorithm family as the + * id_token signature (SHA-256 for RS256), take the left-most half + * (16 bytes / 128 bits), base64url-encode without padding. + */ + protected function leftHalfHash(string $value): string + { + return $this->base64UrlEncode(\substr(\hash('sha256', $value, true), 0, 16)); + } +} diff --git a/src/Auth/Issuers/Symmetric.php b/src/Auth/Issuers/Symmetric.php new file mode 100644 index 0000000..648ac6a --- /dev/null +++ b/src/Auth/Issuers/Symmetric.php @@ -0,0 +1,85 @@ +secret = $secret; + $this->keyId = $keyId; + } + + /** + * Generate a cryptographically strong secret suitable for HS256 signing, + * as a random hex string. + * + * @throws \Exception When sufficient randomness is unavailable. + */ + public static function generateSecret(int $bytes = 32): string + { + return \bin2hex(\random_bytes($bytes)); + } + + /** + * Get the configured JWS "kid", or null when none was supplied. + */ + public function getKeyId(): ?string + { + return $this->keyId; + } + + protected function getAlgorithm(): string + { + return 'HS256'; + } + + /** + * @return array + */ + protected function getHeaders(): array + { + return $this->keyId !== null ? ['kid' => $this->keyId] : []; + } + + protected function signInput(string $signingInput): string + { + return \hash_hmac('sha256', $signingInput, $this->secret, true); + } +} diff --git a/src/Auth/Issuers/Symmetric/RefreshToken.php b/src/Auth/Issuers/Symmetric/RefreshToken.php new file mode 100644 index 0000000..29ae784 --- /dev/null +++ b/src/Auth/Issuers/Symmetric/RefreshToken.php @@ -0,0 +1,70 @@ + $scopes Granted scopes; joined into the space-delimited "scope" claim when non-empty. + * @param string|null $jti The "jti" claim; a random identifier is generated when null. + * @param array $claims Additional claims to merge into the payload. + * + * @throws \Exception When signing fails. + */ + public function issue( + string $subject, + string $audience, + string $clientId, + int $duration, + array $scopes = [], + ?string $jti = null, + array $claims = [], + ): string { + $now = \time(); + + $claims = \array_merge($claims, [ + 'iss' => $this->issuer, + 'aud' => $audience, + 'sub' => $subject, + 'client_id' => $clientId, + 'exp' => $now + $duration, + 'iat' => $now, + 'jti' => $jti ?? $this->generateJti(), + ]); + + if (!empty($scopes)) { + $claims['scope'] = \implode(' ', $scopes); + } + + return $this->sign($claims); + } +} diff --git a/tests/Auth/Issuers/Asymmetric/AccessTokenTest.php b/tests/Auth/Issuers/Asymmetric/AccessTokenTest.php new file mode 100644 index 0000000..5a84b2c --- /dev/null +++ b/tests/Auth/Issuers/Asymmetric/AccessTokenTest.php @@ -0,0 +1,128 @@ +privateKey, $this->publicKey] = AccessToken::generateKeyPair(); + + $this->accessToken = new AccessToken( + $this->privateKey, + $this->publicKey, + 'https://example.com/v1/oauth2/test', + ); + } + + /** + * @return array + */ + private function decodeSegment(string $segment): array + { + return \json_decode(\base64_decode(\strtr($segment, '-_', '+/')), true); + } + + public function testHeaderType(): void + { + $token = $this->accessToken->issue('user-123', 'https://api.example.com', 'client-abc', 1000, 3600); + $header = $this->decodeSegment(\explode('.', $token)[0]); + + // RFC 9068 §2.1: access tokens carry the "at+jwt" media type. + $this->assertEquals('at+jwt', $header['typ']); + $this->assertEquals('RS256', $header['alg']); + $this->assertEquals($this->accessToken->getKeyId(), $header['kid']); + } + + public function testClaims(): void + { + $before = \time(); + $token = $this->accessToken->issue('user-123', 'https://api.example.com', 'client-abc', 1000, 3600, ['read', 'write']); + $after = \time(); + + $claims = $this->decodeSegment(\explode('.', $token)[1]); + + $this->assertEquals('https://example.com/v1/oauth2/test', $claims['iss']); + $this->assertEquals('https://api.example.com', $claims['aud']); + $this->assertEquals('user-123', $claims['sub']); + $this->assertEquals('client-abc', $claims['client_id']); + $this->assertEquals('read write', $claims['scope']); + $this->assertEquals(1000, $claims['auth_time']); + $this->assertGreaterThanOrEqual($before, $claims['iat']); + $this->assertLessThanOrEqual($after, $claims['iat']); + $this->assertEquals($claims['iat'] + 3600, $claims['exp']); + $this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $claims['jti']); + } + + public function testSignatureIsValid(): void + { + $token = $this->accessToken->issue('user-123', 'https://api.example.com', 'client-abc', 1000, 3600); + + $parts = \explode('.', $token); + $result = \openssl_verify( + $parts[0] . '.' . $parts[1], + \base64_decode(\strtr($parts[2], '-_', '+/')), + $this->publicKey, + OPENSSL_ALGO_SHA256 + ); + + $this->assertEquals(1, $result); + } + + public function testScopeOmittedWhenEmpty(): void + { + $token = $this->accessToken->issue('user-123', 'https://api.example.com', 'client-abc', 1000, 3600); + $claims = $this->decodeSegment(\explode('.', $token)[1]); + + $this->assertArrayNotHasKey('scope', $claims); + } + + public function testJtiIsGeneratedAndUnique(): void + { + $first = $this->decodeSegment(\explode('.', $this->accessToken->issue('user-123', 'aud', 'client', 1000, 3600))[1]); + $second = $this->decodeSegment(\explode('.', $this->accessToken->issue('user-123', 'aud', 'client', 1000, 3600))[1]); + + $this->assertNotEquals($first['jti'], $second['jti']); + } + + public function testCustomJti(): void + { + $token = $this->accessToken->issue('user-123', 'aud', 'client', 1000, 3600, [], 'fixed-jti'); + $claims = $this->decodeSegment(\explode('.', $token)[1]); + + $this->assertEquals('fixed-jti', $claims['jti']); + } + + public function testAdditionalClaims(): void + { + $token = $this->accessToken->issue('user-123', 'aud', 'client', 1000, 3600, [], null, [ + 'tokenId' => 'identity-row-1', + ]); + $claims = $this->decodeSegment(\explode('.', $token)[1]); + + $this->assertEquals('identity-row-1', $claims['tokenId']); + } + + public function testAdditionalClaimsCannotOverrideRegisteredClaims(): void + { + $token = $this->accessToken->issue('user-123', 'aud', 'client', 1000, 3600, [], null, [ + 'sub' => 'attacker', + 'iss' => 'https://evil.example.com', + 'client_id' => 'evil', + ]); + $claims = $this->decodeSegment(\explode('.', $token)[1]); + + $this->assertEquals('user-123', $claims['sub']); + $this->assertEquals('https://example.com/v1/oauth2/test', $claims['iss']); + $this->assertEquals('client', $claims['client_id']); + } +} diff --git a/tests/Auth/OIDCTest.php b/tests/Auth/Issuers/Asymmetric/IdTokenTest.php similarity index 67% rename from tests/Auth/OIDCTest.php rename to tests/Auth/Issuers/Asymmetric/IdTokenTest.php index 1197e22..1361707 100644 --- a/tests/Auth/OIDCTest.php +++ b/tests/Auth/Issuers/Asymmetric/IdTokenTest.php @@ -1,23 +1,23 @@ privateKey, $this->publicKey] = OIDC::generateKeyPair(); + [$this->privateKey, $this->publicKey] = IdToken::generateKeyPair(); - $this->oidc = new OIDC( + $this->idToken = new IdToken( $this->privateKey, $this->publicKey, 'https://example.com/v1/oauth2/test', @@ -38,7 +38,7 @@ private function decodeSegment(string $segment): array public function testIssueStructure(): void { - $token = $this->oidc->issue('user-123', 'client-abc', 1000, 3600); + $token = $this->idToken->issue('user-123', 'client-abc', 1000, 3600); $parts = \explode('.', $token); $this->assertCount(3, $parts); @@ -46,13 +46,13 @@ public function testIssueStructure(): void $header = $this->decodeSegment($parts[0]); $this->assertEquals('JWT', $header['typ']); $this->assertEquals('RS256', $header['alg']); - $this->assertEquals($this->oidc->getKeyId(), $header['kid']); + $this->assertEquals($this->idToken->getKeyId(), $header['kid']); } public function testIssueClaims(): void { $before = \time(); - $token = $this->oidc->issue('user-123', 'client-abc', 1000, 3600); + $token = $this->idToken->issue('user-123', 'client-abc', 1000, 3600); $after = \time(); $claims = $this->decodeSegment(\explode('.', $token)[1]); @@ -73,7 +73,7 @@ public function testIssueClaims(): void public function testSignatureIsValid(): void { - $token = $this->oidc->issue('user-123', 'client-abc', 1000, 3600); + $token = $this->idToken->issue('user-123', 'client-abc', 1000, 3600); $parts = \explode('.', $token); $signingInput = $parts[0] . '.' . $parts[1]; @@ -91,7 +91,7 @@ public function testSignatureIsValid(): void public function testNonceClaim(): void { - $token = $this->oidc->issue('user-123', 'client-abc', 1000, 3600, 'n-0S6_WzA2Mj'); + $token = $this->idToken->issue('user-123', 'client-abc', 1000, 3600, 'n-0S6_WzA2Mj'); $claims = $this->decodeSegment(\explode('.', $token)[1]); $this->assertEquals('n-0S6_WzA2Mj', $claims['nonce']); @@ -102,7 +102,7 @@ public function testAtHashAndCHash(): void $accessToken = 'access-token-value'; $code = 'authorization-code-value'; - $token = $this->oidc->issue('user-123', 'client-abc', 1000, 3600, null, $accessToken, $code); + $token = $this->idToken->issue('user-123', 'client-abc', 1000, 3600, null, $accessToken, $code); $claims = $this->decodeSegment(\explode('.', $token)[1]); $expectedAtHash = $this->expectedLeftHalfHash($accessToken); @@ -114,7 +114,7 @@ public function testAtHashAndCHash(): void public function testAdditionalClaims(): void { - $token = $this->oidc->issue('user-123', 'client-abc', 1000, 3600, null, null, null, [ + $token = $this->idToken->issue('user-123', 'client-abc', 1000, 3600, null, null, null, [ 'email' => 'user@example.com', 'email_verified' => true, ]); @@ -126,7 +126,7 @@ public function testAdditionalClaims(): void public function testAdditionalClaimsCannotOverrideRegisteredClaims(): void { - $token = $this->oidc->issue('user-123', 'client-abc', 1000, 3600, null, null, null, [ + $token = $this->idToken->issue('user-123', 'client-abc', 1000, 3600, null, null, null, [ 'sub' => 'attacker', 'iss' => 'https://evil.example.com', ]); @@ -138,31 +138,31 @@ public function testAdditionalClaimsCannotOverrideRegisteredClaims(): void public function testKeyIdIsDeterministic(): void { - $other = new OIDC($this->privateKey, $this->publicKey, 'https://example.com/v1/oauth2/test'); + $other = new IdToken($this->privateKey, $this->publicKey, 'https://example.com/v1/oauth2/test'); - $this->assertEquals($this->oidc->getKeyId(), $other->getKeyId()); - $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $this->oidc->getKeyId()); + $this->assertEquals($this->idToken->getKeyId(), $other->getKeyId()); + $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $this->idToken->getKeyId()); } public function testCustomKeyId(): void { - $oidc = new OIDC($this->privateKey, $this->publicKey, 'https://example.com', 'my-custom-kid'); + $idToken = new IdToken($this->privateKey, $this->publicKey, 'https://example.com', 'my-custom-kid'); - $this->assertEquals('my-custom-kid', $oidc->getKeyId()); + $this->assertEquals('my-custom-kid', $idToken->getKeyId()); - $token = $oidc->issue('user-123', 'client-abc', 1000, 3600); + $token = $idToken->issue('user-123', 'client-abc', 1000, 3600); $header = $this->decodeSegment(\explode('.', $token)[0]); $this->assertEquals('my-custom-kid', $header['kid']); } public function testGetPublicJwk(): void { - $jwk = $this->oidc->getPublicJwk(); + $jwk = $this->idToken->getPublicJwk(); $this->assertEquals('RSA', $jwk['kty']); $this->assertEquals('sig', $jwk['use']); $this->assertEquals('RS256', $jwk['alg']); - $this->assertEquals($this->oidc->getKeyId(), $jwk['kid']); + $this->assertEquals($this->idToken->getKeyId(), $jwk['kid']); $this->assertNotEmpty($jwk['n']); $this->assertNotEmpty($jwk['e']); // base64url: no padding, no +/ characters @@ -174,31 +174,31 @@ public function testGetPublicJwk(): void public function testEmptyPrivateKeyThrows(): void { $this->expectException(\Exception::class); - new OIDC('', $this->publicKey, 'https://example.com'); + new IdToken('', $this->publicKey, 'https://example.com'); } public function testEmptyPublicKeyThrows(): void { $this->expectException(\Exception::class); - new OIDC($this->privateKey, '', 'https://example.com'); + new IdToken($this->privateKey, '', 'https://example.com'); } public function testEmptyIssuerThrows(): void { $this->expectException(\Exception::class); - new OIDC($this->privateKey, $this->publicKey, ''); + new IdToken($this->privateKey, $this->publicKey, ''); } public function testGenerateKeyPair(): void { - [$privateKey, $publicKey] = OIDC::generateKeyPair(); + [$privateKey, $publicKey] = IdToken::generateKeyPair(); $this->assertStringContainsString('PRIVATE KEY', $privateKey); $this->assertStringContainsString('PUBLIC KEY', $publicKey); // The generated keys are usable for issuing and verifying a token. - $oidc = new OIDC($privateKey, $publicKey, 'https://example.com'); - $token = $oidc->issue('user-123', 'client-abc', 1000, 3600); + $idToken = new IdToken($privateKey, $publicKey, 'https://example.com'); + $token = $idToken->issue('user-123', 'client-abc', 1000, 3600); $parts = \explode('.', $token); $result = \openssl_verify( @@ -210,28 +210,8 @@ public function testGenerateKeyPair(): void $this->assertEquals(1, $result); } - public function testGeneratePublicKeyFromPrivateKey(): void - { - $privateKey = OIDC::generatePrivateKey(); - $publicKey = OIDC::generatePublicKey($privateKey); - - $this->assertStringContainsString('PUBLIC KEY', $publicKey); - - // The derived public key matches the private key it was derived from. - $oidc = new OIDC($privateKey, $publicKey, 'https://example.com'); - $token = $oidc->issue('user-123', 'client-abc', 1000, 3600); - $parts = \explode('.', $token); - $result = \openssl_verify( - $parts[0] . '.' . $parts[1], - \base64_decode(\strtr($parts[2], '-_', '+/')), - $publicKey, - OPENSSL_ALGO_SHA256 - ); - $this->assertEquals(1, $result); - } - /** - * Mirror of OIDC::leftHalfHash for assertion purposes. + * Mirror of IdToken::leftHalfHash for assertion purposes. */ private function expectedLeftHalfHash(string $value): string { diff --git a/tests/Auth/Issuers/Symmetric/RefreshTokenTest.php b/tests/Auth/Issuers/Symmetric/RefreshTokenTest.php new file mode 100644 index 0000000..ac932c0 --- /dev/null +++ b/tests/Auth/Issuers/Symmetric/RefreshTokenTest.php @@ -0,0 +1,154 @@ +secret = RefreshToken::generateSecret(); + + $this->refreshToken = new RefreshToken( + $this->secret, + 'https://example.com/v1/oauth2/test', + ); + } + + /** + * @return array + */ + private function decodeSegment(string $segment): array + { + return \json_decode(\base64_decode(\strtr($segment, '-_', '+/')), true); + } + + private function base64UrlEncode(string $value): string + { + return \rtrim(\strtr(\base64_encode($value), '+/', '-_'), '='); + } + + public function testHeaderUsesHs256AndNoKidByDefault(): void + { + $token = $this->refreshToken->issue('user-123', 'https://example.com/token', 'client-abc', 1209600); + $header = $this->decodeSegment(\explode('.', $token)[0]); + + $this->assertEquals('JWT', $header['typ']); + $this->assertEquals('HS256', $header['alg']); + $this->assertArrayNotHasKey('kid', $header); + } + + public function testClaims(): void + { + $before = \time(); + $token = $this->refreshToken->issue('user-123', 'https://example.com/token', 'client-abc', 1209600, ['read', 'offline_access']); + $after = \time(); + + $claims = $this->decodeSegment(\explode('.', $token)[1]); + + $this->assertEquals('https://example.com/v1/oauth2/test', $claims['iss']); + $this->assertEquals('https://example.com/token', $claims['aud']); + $this->assertEquals('user-123', $claims['sub']); + $this->assertEquals('client-abc', $claims['client_id']); + $this->assertEquals('read offline_access', $claims['scope']); + $this->assertGreaterThanOrEqual($before, $claims['iat']); + $this->assertLessThanOrEqual($after, $claims['iat']); + $this->assertEquals($claims['iat'] + 1209600, $claims['exp']); + $this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $claims['jti']); + + // Refresh tokens carry no auth_time. + $this->assertArrayNotHasKey('auth_time', $claims); + } + + public function testSignatureIsValidHmac(): void + { + $token = $this->refreshToken->issue('user-123', 'aud', 'client-abc', 1209600); + + $parts = \explode('.', $token); + $expected = $this->base64UrlEncode(\hash_hmac('sha256', $parts[0] . '.' . $parts[1], $this->secret, true)); + + $this->assertEquals($expected, $parts[2]); + } + + public function testSignatureFailsWithWrongSecret(): void + { + $token = $this->refreshToken->issue('user-123', 'aud', 'client-abc', 1209600); + + $parts = \explode('.', $token); + $wrong = $this->base64UrlEncode(\hash_hmac('sha256', $parts[0] . '.' . $parts[1], 'not-the-secret', true)); + + $this->assertNotEquals($wrong, $parts[2]); + } + + public function testScopeOmittedWhenEmpty(): void + { + $token = $this->refreshToken->issue('user-123', 'aud', 'client-abc', 1209600); + $claims = $this->decodeSegment(\explode('.', $token)[1]); + + $this->assertArrayNotHasKey('scope', $claims); + } + + public function testJtiIsGeneratedAndUnique(): void + { + $first = $this->decodeSegment(\explode('.', $this->refreshToken->issue('u', 'a', 'c', 100))[1]); + $second = $this->decodeSegment(\explode('.', $this->refreshToken->issue('u', 'a', 'c', 100))[1]); + + $this->assertNotEquals($first['jti'], $second['jti']); + } + + public function testCustomJti(): void + { + $token = $this->refreshToken->issue('u', 'a', 'c', 100, [], 'fixed-jti'); + $claims = $this->decodeSegment(\explode('.', $token)[1]); + + $this->assertEquals('fixed-jti', $claims['jti']); + } + + public function testKidHeaderWhenConfigured(): void + { + $refreshToken = new RefreshToken($this->secret, 'https://example.com/v1/oauth2/test', 'secret-v2'); + + $this->assertEquals('secret-v2', $refreshToken->getKeyId()); + + $header = $this->decodeSegment(\explode('.', $refreshToken->issue('u', 'a', 'c', 100))[0]); + $this->assertEquals('secret-v2', $header['kid']); + } + + public function testAdditionalClaimsCannotOverrideRegisteredClaims(): void + { + $token = $this->refreshToken->issue('user-123', 'aud', 'client', 100, [], null, [ + 'sub' => 'attacker', + 'iss' => 'https://evil.example.com', + ]); + $claims = $this->decodeSegment(\explode('.', $token)[1]); + + $this->assertEquals('user-123', $claims['sub']); + $this->assertEquals('https://example.com/v1/oauth2/test', $claims['iss']); + } + + public function testGenerateSecret(): void + { + $secret = RefreshToken::generateSecret(); + + $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $secret); + $this->assertNotEquals($secret, RefreshToken::generateSecret()); + } + + public function testEmptySecretThrows(): void + { + $this->expectException(\Exception::class); + new RefreshToken('', 'https://example.com/v1/oauth2/test'); + } + + public function testEmptyIssuerThrows(): void + { + $this->expectException(\Exception::class); + new RefreshToken($this->secret, ''); + } +} From a5e750efc17ab8b8efac6cadd501bdc1a1a33099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 30 May 2026 09:57:20 +0200 Subject: [PATCH 3/8] Linter fixes --- src/Auth/Issuer.php | 2 ++ src/Auth/Issuers/Symmetric.php | 2 ++ tests/Auth/Issuers/Asymmetric/AccessTokenTest.php | 7 +++++-- tests/Auth/Issuers/Asymmetric/IdTokenTest.php | 5 ++++- tests/Auth/Issuers/Symmetric/RefreshTokenTest.php | 7 +++++-- 5 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/Auth/Issuer.php b/src/Auth/Issuer.php index b069668..fed3895 100644 --- a/src/Auth/Issuer.php +++ b/src/Auth/Issuer.php @@ -88,6 +88,8 @@ protected function sign(array $claims): string * Generate a unique token identifier suitable for the "jti" claim * (RFC 7519 §4.1.7) as a random hex string. * + * @param int<1, max> $bytes + * * @throws \Exception When sufficient randomness is unavailable. */ protected function generateJti(int $bytes = 16): string diff --git a/src/Auth/Issuers/Symmetric.php b/src/Auth/Issuers/Symmetric.php index 648ac6a..dd5c5b9 100644 --- a/src/Auth/Issuers/Symmetric.php +++ b/src/Auth/Issuers/Symmetric.php @@ -50,6 +50,8 @@ public function __construct( * Generate a cryptographically strong secret suitable for HS256 signing, * as a random hex string. * + * @param int<1, max> $bytes + * * @throws \Exception When sufficient randomness is unavailable. */ public static function generateSecret(int $bytes = 32): string diff --git a/tests/Auth/Issuers/Asymmetric/AccessTokenTest.php b/tests/Auth/Issuers/Asymmetric/AccessTokenTest.php index 5a84b2c..9ec5c8a 100644 --- a/tests/Auth/Issuers/Asymmetric/AccessTokenTest.php +++ b/tests/Auth/Issuers/Asymmetric/AccessTokenTest.php @@ -29,7 +29,10 @@ protected function setUp(): void */ private function decodeSegment(string $segment): array { - return \json_decode(\base64_decode(\strtr($segment, '-_', '+/')), true); + /** @var array $claims */ + $claims = \json_decode(\base64_decode(\strtr($segment, '-_', '+/')), true); + + return $claims; } public function testHeaderType(): void @@ -60,7 +63,7 @@ public function testClaims(): void $this->assertGreaterThanOrEqual($before, $claims['iat']); $this->assertLessThanOrEqual($after, $claims['iat']); $this->assertEquals($claims['iat'] + 3600, $claims['exp']); - $this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $claims['jti']); + $this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', (string) $claims['jti']); } public function testSignatureIsValid(): void diff --git a/tests/Auth/Issuers/Asymmetric/IdTokenTest.php b/tests/Auth/Issuers/Asymmetric/IdTokenTest.php index 1361707..2f01db8 100644 --- a/tests/Auth/Issuers/Asymmetric/IdTokenTest.php +++ b/tests/Auth/Issuers/Asymmetric/IdTokenTest.php @@ -33,7 +33,10 @@ private function decodeSegment(string $segment): array { $json = \base64_decode(\strtr($segment, '-_', '+/')); - return \json_decode($json, true); + /** @var array $claims */ + $claims = \json_decode($json, true); + + return $claims; } public function testIssueStructure(): void diff --git a/tests/Auth/Issuers/Symmetric/RefreshTokenTest.php b/tests/Auth/Issuers/Symmetric/RefreshTokenTest.php index ac932c0..1e411a1 100644 --- a/tests/Auth/Issuers/Symmetric/RefreshTokenTest.php +++ b/tests/Auth/Issuers/Symmetric/RefreshTokenTest.php @@ -26,7 +26,10 @@ protected function setUp(): void */ private function decodeSegment(string $segment): array { - return \json_decode(\base64_decode(\strtr($segment, '-_', '+/')), true); + /** @var array $claims */ + $claims = \json_decode(\base64_decode(\strtr($segment, '-_', '+/')), true); + + return $claims; } private function base64UrlEncode(string $value): string @@ -60,7 +63,7 @@ public function testClaims(): void $this->assertGreaterThanOrEqual($before, $claims['iat']); $this->assertLessThanOrEqual($after, $claims['iat']); $this->assertEquals($claims['iat'] + 1209600, $claims['exp']); - $this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $claims['jti']); + $this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', (string) $claims['jti']); // Refresh tokens carry no auth_time. $this->assertArrayNotHasKey('auth_time', $claims); From 0bdf5dcf271fbaefa8fd10b8d12a6fa9ba9809de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 30 May 2026 09:57:44 +0200 Subject: [PATCH 4/8] PR review fixes --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 76ea733..9081ac8 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,7 @@ "require": { "php": ">=8.0", "ext-hash": "*", + "ext-openssl": "*", "ext-scrypt": "*", "ext-sodium": "*" }, From 02a1abb2527e918ec643abf0edb51cd4ed0b418f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 30 May 2026 09:58:37 +0200 Subject: [PATCH 5/8] Simplify key pairs interface --- src/Auth/Issuers/Asymmetric.php | 36 ++++++--------------------------- 1 file changed, 6 insertions(+), 30 deletions(-) diff --git a/src/Auth/Issuers/Asymmetric.php b/src/Auth/Issuers/Asymmetric.php index bc4293b..31b1b07 100644 --- a/src/Auth/Issuers/Asymmetric.php +++ b/src/Auth/Issuers/Asymmetric.php @@ -77,30 +77,18 @@ public static function generateKeyPair(int $bits = 2048): array } return [ - self::generatePrivateKey($resource), - self::generatePublicKey($resource), + self::exportPrivateKey($resource), + self::exportPublicKey($resource), ]; } /** - * Export the PEM-encoded private key from an OpenSSL key resource, or - * generate a fresh keypair and return its private key when none is given. - * - * @param \OpenSSLAsymmetricKey|null $resource An existing key resource, or null to create one. + * Export the PEM-encoded private key from an OpenSSL key resource. * * @throws \Exception When the key cannot be exported. */ - private static function generatePrivateKey(?\OpenSSLAsymmetricKey $resource = null, int $bits = 2048): string + private static function exportPrivateKey(\OpenSSLAsymmetricKey $resource): string { - $resource ??= \openssl_pkey_new([ - 'private_key_bits' => $bits, - 'private_key_type' => OPENSSL_KEYTYPE_RSA, - ]); - - if ($resource === false) { - throw new \Exception('Unable to generate an RSA key pair'); - } - $privateKey = ''; if (!\openssl_pkey_export($resource, $privateKey)) { throw new \Exception('Unable to export the private key'); @@ -110,24 +98,12 @@ private static function generatePrivateKey(?\OpenSSLAsymmetricKey $resource = nu } /** - * Export the PEM-encoded public key, either from an existing OpenSSL key - * resource or from a PEM-encoded private key string. - * - * @param \OpenSSLAsymmetricKey|string $key A key resource or a PEM-encoded private key. + * Export the PEM-encoded public key from an OpenSSL key resource. * * @throws \Exception When the public key cannot be derived. */ - private static function generatePublicKey(\OpenSSLAsymmetricKey|string $key): string + private static function exportPublicKey(\OpenSSLAsymmetricKey $resource): string { - if (\is_string($key)) { - $resource = \openssl_pkey_get_private($key); - if ($resource === false) { - throw new \Exception('Unable to parse the private key'); - } - } else { - $resource = $key; - } - $details = \openssl_pkey_get_details($resource); if ($details === false || !isset($details['key'])) { throw new \Exception('Unable to export the public key'); From ad60f6713e5f9edc87ba17ce88fa2e9ddf055725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 30 May 2026 10:00:01 +0200 Subject: [PATCH 6/8] Performance improvement --- src/Auth/Issuers/Asymmetric.php | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/Auth/Issuers/Asymmetric.php b/src/Auth/Issuers/Asymmetric.php index 31b1b07..0ce8506 100644 --- a/src/Auth/Issuers/Asymmetric.php +++ b/src/Auth/Issuers/Asymmetric.php @@ -121,13 +121,7 @@ private static function exportPublicKey(\OpenSSLAsymmetricKey $resource): string */ public function getKeyId(): string { - if ($this->keyId !== null) { - return $this->keyId; - } - - $this->keyId = \hash('sha256', $this->getModulus()); - - return $this->keyId; + return $this->keyId ??= self::deriveKeyId($this->getModulus()); } /** @@ -154,7 +148,9 @@ public function getPublicJwk(): array 'kty' => 'RSA', 'use' => 'sig', 'alg' => 'RS256', - 'kid' => $this->getKeyId(), + // Reuse the modulus already in $details rather than re-parsing + // the key via getKeyId() -> getModulus(). + 'kid' => $this->keyId ??= self::deriveKeyId($details['rsa']['n']), 'n' => $this->base64UrlEncode($details['rsa']['n']), 'e' => $this->base64UrlEncode($details['rsa']['e']), ]; @@ -193,6 +189,15 @@ protected function signInput(string $signingInput): string return $signature; } + /** + * Derive a deterministic key id from the RSA modulus, so the same key + * always yields the same "kid". + */ + private static function deriveKeyId(string $modulus): string + { + return \hash('sha256', $modulus); + } + /** * Read the raw RSA modulus (the "n" parameter) from the public key. * From 1c842591fe682933187aa02d562b5233efe5e7d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 30 May 2026 10:04:38 +0200 Subject: [PATCH 7/8] PR review fixes --- src/Auth/Issuer.php | 5 ++- src/Auth/Issuers/Asymmetric/AccessToken.php | 4 ++ src/Auth/Issuers/Asymmetric/IdToken.php | 6 +++ src/Auth/Issuers/Symmetric/RefreshToken.php | 4 ++ .../Issuers/Asymmetric/AccessTokenTest.php | 24 ++++++++++- tests/Auth/Issuers/Asymmetric/IdTokenTest.php | 43 +++++++++++++++++++ .../Issuers/Symmetric/RefreshTokenTest.php | 24 ++++++++++- 7 files changed, 106 insertions(+), 4 deletions(-) diff --git a/src/Auth/Issuer.php b/src/Auth/Issuer.php index fed3895..62fc56c 100644 --- a/src/Auth/Issuer.php +++ b/src/Auth/Issuer.php @@ -68,6 +68,7 @@ protected function getHeaders(): array * * @param array $claims * + * @throws \JsonException When the header or claims cannot be JSON-encoded. * @throws \Exception When signing fails. */ protected function sign(array $claims): string @@ -77,9 +78,9 @@ protected function sign(array $claims): string 'alg' => $this->getAlgorithm(), ], $this->getHeaders()); - $signingInput = $this->base64UrlEncode((string) \json_encode($header)) + $signingInput = $this->base64UrlEncode(\json_encode($header, JSON_THROW_ON_ERROR)) . '.' - . $this->base64UrlEncode((string) \json_encode($claims)); + . $this->base64UrlEncode(\json_encode($claims, JSON_THROW_ON_ERROR)); return $signingInput . '.' . $this->base64UrlEncode($this->signInput($signingInput)); } diff --git a/src/Auth/Issuers/Asymmetric/AccessToken.php b/src/Auth/Issuers/Asymmetric/AccessToken.php index 01e8216..555f588 100644 --- a/src/Auth/Issuers/Asymmetric/AccessToken.php +++ b/src/Auth/Issuers/Asymmetric/AccessToken.php @@ -56,6 +56,10 @@ public function issue( ): string { $now = \time(); + // "scope" is issuer-controlled; drop any caller-supplied value so it + // cannot be injected through $claims when $scopes is empty. + unset($claims['scope']); + $claims = \array_merge($claims, [ 'iss' => $this->issuer, 'aud' => $audience, diff --git a/src/Auth/Issuers/Asymmetric/IdToken.php b/src/Auth/Issuers/Asymmetric/IdToken.php index a400a39..1ffc010 100644 --- a/src/Auth/Issuers/Asymmetric/IdToken.php +++ b/src/Auth/Issuers/Asymmetric/IdToken.php @@ -54,6 +54,12 @@ public function issue( ): string { $now = \time(); + // nonce/at_hash/c_hash are issuer-controlled; drop any caller-supplied + // values so they cannot be injected through $claims when the matching + // parameter is absent (e.g. a forged at_hash binding the id_token to an + // access token that was never co-issued). + unset($claims['nonce'], $claims['at_hash'], $claims['c_hash']); + $claims = \array_merge($claims, [ 'iss' => $this->issuer, 'sub' => $subject, diff --git a/src/Auth/Issuers/Symmetric/RefreshToken.php b/src/Auth/Issuers/Symmetric/RefreshToken.php index 29ae784..e03fbf4 100644 --- a/src/Auth/Issuers/Symmetric/RefreshToken.php +++ b/src/Auth/Issuers/Symmetric/RefreshToken.php @@ -51,6 +51,10 @@ public function issue( ): string { $now = \time(); + // "scope" is issuer-controlled; drop any caller-supplied value so it + // cannot be injected through $claims when $scopes is empty. + unset($claims['scope']); + $claims = \array_merge($claims, [ 'iss' => $this->issuer, 'aud' => $audience, diff --git a/tests/Auth/Issuers/Asymmetric/AccessTokenTest.php b/tests/Auth/Issuers/Asymmetric/AccessTokenTest.php index 9ec5c8a..8039cf1 100644 --- a/tests/Auth/Issuers/Asymmetric/AccessTokenTest.php +++ b/tests/Auth/Issuers/Asymmetric/AccessTokenTest.php @@ -63,7 +63,9 @@ public function testClaims(): void $this->assertGreaterThanOrEqual($before, $claims['iat']); $this->assertLessThanOrEqual($after, $claims['iat']); $this->assertEquals($claims['iat'] + 3600, $claims['exp']); - $this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', (string) $claims['jti']); + $jti = $claims['jti']; + \assert(\is_string($jti)); + $this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $jti); } public function testSignatureIsValid(): void @@ -89,6 +91,26 @@ public function testScopeOmittedWhenEmpty(): void $this->assertArrayNotHasKey('scope', $claims); } + public function testScopeCannotBeInjectedViaClaimsWhenEmpty(): void + { + $token = $this->accessToken->issue('user-123', 'https://api.example.com', 'client-abc', 1000, 3600, [], null, [ + 'scope' => 'admin', + ]); + $claims = $this->decodeSegment(\explode('.', $token)[1]); + + $this->assertArrayNotHasKey('scope', $claims); + } + + public function testScopeCannotBeOverriddenViaClaims(): void + { + $token = $this->accessToken->issue('user-123', 'https://api.example.com', 'client-abc', 1000, 3600, ['read'], null, [ + 'scope' => 'admin', + ]); + $claims = $this->decodeSegment(\explode('.', $token)[1]); + + $this->assertEquals('read', $claims['scope']); + } + public function testJtiIsGeneratedAndUnique(): void { $first = $this->decodeSegment(\explode('.', $this->accessToken->issue('user-123', 'aud', 'client', 1000, 3600))[1]); diff --git a/tests/Auth/Issuers/Asymmetric/IdTokenTest.php b/tests/Auth/Issuers/Asymmetric/IdTokenTest.php index 2f01db8..a1fb332 100644 --- a/tests/Auth/Issuers/Asymmetric/IdTokenTest.php +++ b/tests/Auth/Issuers/Asymmetric/IdTokenTest.php @@ -115,6 +115,49 @@ public function testAtHashAndCHash(): void $this->assertEquals($expectedCHash, $claims['c_hash']); } + public function testHashClaimsCannotBeInjectedViaClaimsWhenAbsent(): void + { + // No nonce/accessToken/code passed, but a caller tries to smuggle them + // (e.g. a forged at_hash) through the additional claims array. + $token = $this->idToken->issue('user-123', 'client-abc', 1000, 3600, null, null, null, [ + 'nonce' => 'forged-nonce', + 'at_hash' => 'forged-at-hash', + 'c_hash' => 'forged-c-hash', + ]); + $claims = $this->decodeSegment(\explode('.', $token)[1]); + + $this->assertArrayNotHasKey('nonce', $claims); + $this->assertArrayNotHasKey('at_hash', $claims); + $this->assertArrayNotHasKey('c_hash', $claims); + } + + public function testHashClaimsCannotBeOverriddenViaClaims(): void + { + $accessToken = 'access-token-value'; + $code = 'authorization-code-value'; + + $token = $this->idToken->issue('user-123', 'client-abc', 1000, 3600, 'real-nonce', $accessToken, $code, [ + 'nonce' => 'forged-nonce', + 'at_hash' => 'forged-at-hash', + 'c_hash' => 'forged-c-hash', + ]); + $claims = $this->decodeSegment(\explode('.', $token)[1]); + + $this->assertEquals('real-nonce', $claims['nonce']); + $this->assertEquals($this->expectedLeftHalfHash($accessToken), $claims['at_hash']); + $this->assertEquals($this->expectedLeftHalfHash($code), $claims['c_hash']); + } + + public function testUnrepresentableClaimThrows(): void + { + // Invalid UTF-8 cannot be JSON-encoded; this must fail loudly rather + // than silently produce a token with an empty payload segment. + $this->expectException(\JsonException::class); + $this->idToken->issue('user-123', 'client-abc', 1000, 3600, null, null, null, [ + 'bad' => "\xB1\x31", + ]); + } + public function testAdditionalClaims(): void { $token = $this->idToken->issue('user-123', 'client-abc', 1000, 3600, null, null, null, [ diff --git a/tests/Auth/Issuers/Symmetric/RefreshTokenTest.php b/tests/Auth/Issuers/Symmetric/RefreshTokenTest.php index 1e411a1..6e493c3 100644 --- a/tests/Auth/Issuers/Symmetric/RefreshTokenTest.php +++ b/tests/Auth/Issuers/Symmetric/RefreshTokenTest.php @@ -63,7 +63,9 @@ public function testClaims(): void $this->assertGreaterThanOrEqual($before, $claims['iat']); $this->assertLessThanOrEqual($after, $claims['iat']); $this->assertEquals($claims['iat'] + 1209600, $claims['exp']); - $this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', (string) $claims['jti']); + $jti = $claims['jti']; + \assert(\is_string($jti)); + $this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $jti); // Refresh tokens carry no auth_time. $this->assertArrayNotHasKey('auth_time', $claims); @@ -97,6 +99,26 @@ public function testScopeOmittedWhenEmpty(): void $this->assertArrayNotHasKey('scope', $claims); } + public function testScopeCannotBeInjectedViaClaimsWhenEmpty(): void + { + $token = $this->refreshToken->issue('user-123', 'aud', 'client-abc', 1209600, [], null, [ + 'scope' => 'admin', + ]); + $claims = $this->decodeSegment(\explode('.', $token)[1]); + + $this->assertArrayNotHasKey('scope', $claims); + } + + public function testScopeCannotBeOverriddenViaClaims(): void + { + $token = $this->refreshToken->issue('user-123', 'aud', 'client-abc', 1209600, ['read'], null, [ + 'scope' => 'admin', + ]); + $claims = $this->decodeSegment(\explode('.', $token)[1]); + + $this->assertEquals('read', $claims['scope']); + } + public function testJtiIsGeneratedAndUnique(): void { $first = $this->decodeSegment(\explode('.', $this->refreshToken->issue('u', 'a', 'c', 100))[1]); From 3e6f695e581b49bd774bf17d9085ccf920528f9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 30 May 2026 12:37:43 +0200 Subject: [PATCH 8/8] Add issuers to readme --- README.md | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/README.md b/README.md index d210d25..b3df7dc 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ Utopia Framework requires PHP 8.0 or later. We recommend using the latest PHP ve - **PHPass** - Portable password hashing framework - **MD5** (Not recommended for passwords, legacy support only) +### Token Issuers + +A generic framework for minting signed [JWS](https://datatracker.ietf.org/doc/html/rfc7515) tokens. The base `Issuer` is **not** tied to any particular protocol — it owns the JWS mechanics (header assembly, `jti` generation, base64url encoding and the header/payload/signature structure) and delegates only the signing algorithm and claim set to a subclass. + ## Usage ### Data Store @@ -165,6 +169,93 @@ $argon2 ->setThreads(3); // Number of threads ``` +### Issuing Tokens + +#### OAuth2 Access Tokens (RFC 9068) + +```php +issue( + subject: 'user-123', // "sub" — the resource owner + audience: 'https://api.example.com', // "aud" — the resource server + clientId: 'client-abc', // "client_id" — the client it was issued to + authTime: time(), // "auth_time" — when the user authenticated + duration: 3600, // Lifetime in seconds ("exp") + scopes: ['openid', 'profile', 'email'] +); + +// Publish the public key as a JWK so resource servers can verify tokens +$jwk = $accessToken->getPublicJwk(); +$keyId = $accessToken->getKeyId(); +``` + +#### OAuth2 Refresh Tokens (HS256) + +```php +issue( + subject: 'user-123', // "sub" + audience: 'https://example.com/v1/oauth2/token', // "aud" — the token endpoint + clientId: 'client-abc', // "client_id" + duration: 1209600, // Lifetime in seconds (e.g. 14 days) + scopes: ['openid', 'profile'] +); +``` + +#### ID Tokens (OpenID Connect) + +```php +issue( + subject: 'user-123', // "sub" — the authenticated user + audience: 'client-abc', // "aud" — the client the token is for + authTime: time(), // "auth_time" + duration: 3600, // Lifetime in seconds ("exp") + nonce: 'n-0S6_WzA2Mj', // Optional "nonce" from the auth request + accessToken: $jwt, // Optional co-issued access_token (adds "at_hash") + code: null // Optional co-issued authorization code (adds "c_hash") +); +``` + +> Both asymmetric and symmetric issuers accept an optional `keyId` constructor argument (the JWS `kid` header) for key rotation. For asymmetric issuers it is derived deterministically from the public key when omitted. + ## Tests To run all unit tests, use the following Docker command: