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: 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": "*" }, 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/Issuer.php b/src/Auth/Issuer.php new file mode 100644 index 0000000..62fc56c --- /dev/null +++ b/src/Auth/Issuer.php @@ -0,0 +1,108 @@ +". + */ + 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 \JsonException When the header or claims cannot be JSON-encoded. + * @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(\json_encode($header, JSON_THROW_ON_ERROR)) + . '.' + . $this->base64UrlEncode(\json_encode($claims, JSON_THROW_ON_ERROR)); + + 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. + * + * @param int<1, max> $bytes + * + * @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/Issuers/Asymmetric.php b/src/Auth/Issuers/Asymmetric.php new file mode 100644 index 0000000..0ce8506 --- /dev/null +++ b/src/Auth/Issuers/Asymmetric.php @@ -0,0 +1,220 @@ +privateKey = $privateKey; + $this->publicKey = $publicKey; + $this->keyId = $keyId; + } + + /** + * 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]. + * + * @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::exportPrivateKey($resource), + self::exportPublicKey($resource), + ]; + } + + /** + * Export the PEM-encoded private key from an OpenSSL key resource. + * + * @throws \Exception When the key cannot be exported. + */ + private static function exportPrivateKey(\OpenSSLAsymmetricKey $resource): string + { + $privateKey = ''; + if (!\openssl_pkey_export($resource, $privateKey)) { + throw new \Exception('Unable to export the private key'); + } + + return $privateKey; + } + + /** + * Export the PEM-encoded public key from an OpenSSL key resource. + * + * @throws \Exception When the public key cannot be derived. + */ + private static function exportPublicKey(\OpenSSLAsymmetricKey $resource): string + { + $details = \openssl_pkey_get_details($resource); + if ($details === false || !isset($details['key'])) { + throw new \Exception('Unable to export the public key'); + } + + return $details['key']; + } + + /** + * 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 + { + return $this->keyId ??= self::deriveKeyId($this->getModulus()); + } + + /** + * Build the public key as a JWK (RFC 7517) suitable for publishing on a + * JWKS endpoint so clients can verify the issued 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', + // 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']), + ]; + } + + 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; + } + + /** + * 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. + * + * @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']; + } +} diff --git a/src/Auth/Issuers/Asymmetric/AccessToken.php b/src/Auth/Issuers/Asymmetric/AccessToken.php new file mode 100644 index 0000000..555f588 --- /dev/null +++ b/src/Auth/Issuers/Asymmetric/AccessToken.php @@ -0,0 +1,80 @@ + $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(); + + // "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, + '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..1ffc010 --- /dev/null +++ b/src/Auth/Issuers/Asymmetric/IdToken.php @@ -0,0 +1,96 @@ + $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(); + + // 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, + '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..dd5c5b9 --- /dev/null +++ b/src/Auth/Issuers/Symmetric.php @@ -0,0 +1,87 @@ +secret = $secret; + $this->keyId = $keyId; + } + + /** + * 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 + { + 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..e03fbf4 --- /dev/null +++ b/src/Auth/Issuers/Symmetric/RefreshToken.php @@ -0,0 +1,74 @@ + $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(); + + // "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, + '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..8039cf1 --- /dev/null +++ b/tests/Auth/Issuers/Asymmetric/AccessTokenTest.php @@ -0,0 +1,153 @@ +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 + { + /** @var array $claims */ + $claims = \json_decode(\base64_decode(\strtr($segment, '-_', '+/')), true); + + return $claims; + } + + 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']); + $jti = $claims['jti']; + \assert(\is_string($jti)); + $this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $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 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]); + $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/Issuers/Asymmetric/IdTokenTest.php b/tests/Auth/Issuers/Asymmetric/IdTokenTest.php new file mode 100644 index 0000000..a1fb332 --- /dev/null +++ b/tests/Auth/Issuers/Asymmetric/IdTokenTest.php @@ -0,0 +1,266 @@ +privateKey, $this->publicKey] = IdToken::generateKeyPair(); + + $this->idToken = new IdToken( + $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, '-_', '+/')); + + /** @var array $claims */ + $claims = \json_decode($json, true); + + return $claims; + } + + public function testIssueStructure(): void + { + $token = $this->idToken->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->idToken->getKeyId(), $header['kid']); + } + + public function testIssueClaims(): void + { + $before = \time(); + $token = $this->idToken->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->idToken->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->idToken->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->idToken->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 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, [ + '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->idToken->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 IdToken($this->privateKey, $this->publicKey, 'https://example.com/v1/oauth2/test'); + + $this->assertEquals($this->idToken->getKeyId(), $other->getKeyId()); + $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $this->idToken->getKeyId()); + } + + public function testCustomKeyId(): void + { + $idToken = new IdToken($this->privateKey, $this->publicKey, 'https://example.com', 'my-custom-kid'); + + $this->assertEquals('my-custom-kid', $idToken->getKeyId()); + + $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->idToken->getPublicJwk(); + + $this->assertEquals('RSA', $jwk['kty']); + $this->assertEquals('sig', $jwk['use']); + $this->assertEquals('RS256', $jwk['alg']); + $this->assertEquals($this->idToken->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 IdToken('', $this->publicKey, 'https://example.com'); + } + + public function testEmptyPublicKeyThrows(): void + { + $this->expectException(\Exception::class); + new IdToken($this->privateKey, '', 'https://example.com'); + } + + public function testEmptyIssuerThrows(): void + { + $this->expectException(\Exception::class); + new IdToken($this->privateKey, $this->publicKey, ''); + } + + public function testGenerateKeyPair(): void + { + [$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. + $idToken = new IdToken($privateKey, $publicKey, 'https://example.com'); + $token = $idToken->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 IdToken::leftHalfHash for assertion purposes. + */ + private function expectedLeftHalfHash(string $value): string + { + return \rtrim(\strtr(\base64_encode(\substr(\hash('sha256', $value, true), 0, 16)), '+/', '-_'), '='); + } +} diff --git a/tests/Auth/Issuers/Symmetric/RefreshTokenTest.php b/tests/Auth/Issuers/Symmetric/RefreshTokenTest.php new file mode 100644 index 0000000..6e493c3 --- /dev/null +++ b/tests/Auth/Issuers/Symmetric/RefreshTokenTest.php @@ -0,0 +1,179 @@ +secret = RefreshToken::generateSecret(); + + $this->refreshToken = new RefreshToken( + $this->secret, + 'https://example.com/v1/oauth2/test', + ); + } + + /** + * @return array + */ + private function decodeSegment(string $segment): array + { + /** @var array $claims */ + $claims = \json_decode(\base64_decode(\strtr($segment, '-_', '+/')), true); + + return $claims; + } + + 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']); + $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); + } + + 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 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]); + $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, ''); + } +}