From c609c999b89d1bdc088eddf02c11d56983bd6cca Mon Sep 17 00:00:00 2001 From: James Zhu Date: Mon, 12 May 2025 15:57:12 +1200 Subject: [PATCH 1/9] CMS 5 upgrade --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 14b296d..4ad81ec 100644 --- a/composer.json +++ b/composer.json @@ -23,8 +23,8 @@ "league/oauth2-server": "^8.2", "monolog/monolog": "^1.2", "robbie/psr7-adapters": "^1", - "silverstripe/framework": "^4.13", - "silverstripe/siteconfig": "^4.13" + "silverstripe/framework": "^5", + "silverstripe/siteconfig": "^5" }, "require-dev": { "php-parallel-lint/php-parallel-lint": "^1.3", @@ -34,7 +34,7 @@ "phpstan/phpstan-strict-rules": "^1.5", "phpunit/phpunit": "^9.5", "squizlabs/php_codesniffer": "^3.9", - "syntro/silverstripe-phpstan": "^1.0" + "syntro/silverstripe-phpstan": "^5.0" }, "minimum-stability": "dev", "prefer-stable": true, From a85f862ea23169e3775aa897291bf4526959766f Mon Sep 17 00:00:00 2001 From: Mike Nuguid Date: Fri, 17 Jan 2025 15:22:49 +1300 Subject: [PATCH 2/9] ENH Update to EDDSA(ED25519) signing key --- README.md | 8 ++ .../BearerTokenValidatorEddsa.php | 136 ++++++++++++++++++ code/Entities/AccessTokenEntity.php | 38 +++++ code/OauthServerController.php | 7 +- code/Utility/Utility.php | 28 ++++ composer.json | 1 + tests/OauthServerControllerTest.php | 6 +- tests/private.key | 30 +--- tests/public.key | 8 +- 9 files changed, 223 insertions(+), 39 deletions(-) create mode 100644 code/AuthorizationValidators/BearerTokenValidatorEddsa.php create mode 100644 code/Utility/Utility.php diff --git a/README.md b/README.md index 8cec37d..db2e43a 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,14 @@ chmod 600 private.key chmod 600 public.key ``` +To generate ED25519 private/public key pair: +```shell +openssl genpkey -algorithm Ed25519 -out private.key +openssl pkey -in ed25519-private.key -pubout -out public.key +chmod 600 private.key +chmod 600 public.key +``` + Put these on your web server, somewhere outside the web root Generate encryption key: diff --git a/code/AuthorizationValidators/BearerTokenValidatorEddsa.php b/code/AuthorizationValidators/BearerTokenValidatorEddsa.php new file mode 100644 index 0000000..0a7ec8b --- /dev/null +++ b/code/AuthorizationValidators/BearerTokenValidatorEddsa.php @@ -0,0 +1,136 @@ +accessTokenRepository = $accessTokenRepository; + $this->jwtValidAtDateLeeway = $jwtValidAtDateLeeway; + } + + + /** + * Set the public key + * + * @param CryptKey $key + */ + public function setPublicKey(CryptKey $key) + { + $this->publicKey = $key; + + $this->initJwtConfiguration(); + } + + /** + * Initialise the JWT configuration. + */ + protected function initJwtConfiguration() + { + $this->jwtConfiguration = Configuration::forSymmetricSigner( + new Eddsa(), + InMemory::plainText('empty', 'empty') + ); + + $clock = new SystemClock(new DateTimeZone(\date_default_timezone_get())); + $publicKeyResource = $this->publicKey->getKeyContents(); // Ensure this contains the PEM key + $sodiumPublicKey = Utility::extractDERKeyValue($publicKeyResource); + + $this->jwtConfiguration->setValidationConstraints( + new LooseValidAt($clock, $this->jwtValidAtDateLeeway), + new SignedWith( + new Eddsa(), + InMemory::plainText($sodiumPublicKey, $this->publicKey->getPassPhrase() ?? '') + ) + ); + } + + /** + * {@inheritdoc} + */ + public function validateAuthorization(ServerRequestInterface $request) + { + if ($request->hasHeader('authorization') === false) { + throw OAuthServerException::accessDenied('Missing "Authorization" header'); + } + + $header = $request->getHeader('authorization'); + $jwt = \trim((string) \preg_replace('/^\s*Bearer\s/', '', $header[0])); + + try { + // Attempt to parse the JWT + $token = $this->jwtConfiguration->parser()->parse($jwt); + } catch (\Lcobucci\JWT\Exception $exception) { + throw OAuthServerException::accessDenied($exception->getMessage(), null, $exception); + } + + try { + // Attempt to validate the JWT + $constraints = $this->jwtConfiguration->validationConstraints(); + $this->jwtConfiguration->validator()->assert($token, ...$constraints); + } catch (RequiredConstraintsViolated $exception) { + throw OAuthServerException::accessDenied('Access token could not be verified', null, $exception); + } + + $claims = $token->claims(); + + // Check if token has been revoked + if ($this->accessTokenRepository->isAccessTokenRevoked($claims->get('jti'))) { + throw OAuthServerException::accessDenied('Access token has been revoked'); + } + + // Return the request with additional attributes + return $request + ->withAttribute('oauth_access_token_id', $claims->get('jti')) + ->withAttribute('oauth_client_id', $this->convertSingleRecordAudToString($claims->get('aud'))) + ->withAttribute('oauth_user_id', $claims->get('sub')) + ->withAttribute('oauth_scopes', $claims->get('scopes')); + } + + /** + * Convert single record arrays into strings to ensure backwards compatibility between v4 and v3.x of lcobucci/jwt + * + * @param mixed $aud + * + * @return array|string + */ + private function convertSingleRecordAudToString($aud) + { + return \is_array($aud) && \count($aud) === 1 ? $aud[0] : $aud; + } +} diff --git a/code/Entities/AccessTokenEntity.php b/code/Entities/AccessTokenEntity.php index 7a015d0..c85876f 100644 --- a/code/Entities/AccessTokenEntity.php +++ b/code/Entities/AccessTokenEntity.php @@ -11,6 +11,10 @@ use DateTimeImmutable; use Exception; use IanSimpson\OAuth2\OauthServerController; +use Lcobucci\JWT\Configuration; +use Lcobucci\JWT\Signer\Eddsa; +use Lcobucci\JWT\Signer\Key\InMemory; +use Lcobucci\JWT\Token; use League\OAuth2\Server\Entities\AccessTokenEntityInterface; use League\OAuth2\Server\Entities\ClientEntityInterface; use League\OAuth2\Server\Entities\ScopeEntityInterface; @@ -20,6 +24,9 @@ use SilverStripe\ORM\DataObject; use SilverStripe\ORM\ManyManyList; use SilverStripe\Security\Member; +use Lcobucci\JWT\Builder; +use League\OAuth2\Server\CryptKey; +use IanSimpson\OAuth2\Utility\Utility; /** * @property ?string $Code @@ -82,6 +89,37 @@ class AccessTokenEntity extends DataObject implements AccessTokenEntityInterface 'Code', ]; + /** + * Generate a JWT from the access token + * + * @return Token + */ + public function convertToJWT() + { + // Load the PEM-encoded private key from file (e.g., this can be $this->privateKey->getKeyContents()) + $pemPrivateKey = $this->privateKey->getKeyContents(); // Ensure this contains the PEM key + $sodiumPrivateKey = Utility::extractDERKeyValue($pemPrivateKey); + $secretkey = sodium_crypto_sign_secretkey(sodium_crypto_sign_seed_keypair($sodiumPrivateKey)); + + // Configure the JWT generation + $config = Configuration::forAsymmetricSigner( + new Eddsa(), + InMemory::plainText($secretkey), + InMemory::plainText('empty', 'empty') + ); + + // return the token + return $config->builder() + ->permittedFor($this->getClient()->getIdentifier()) + ->identifiedBy($this->getIdentifier()) + ->issuedAt(new DateTimeImmutable()) + ->canOnlyBeUsedAfter(new DateTimeImmutable()) + ->expiresAt($this->getExpiryDateTime()) + ->relatedTo((string) $this->getUserIdentifier()) + ->withClaim('scopes', $this->getScopes()) + ->getToken($config->signer(), $config->signingKey()); + } + public function getIdentifier(): string { return (string) $this->Code; diff --git a/code/OauthServerController.php b/code/OauthServerController.php index 223cc09..2f22ab2 100644 --- a/code/OauthServerController.php +++ b/code/OauthServerController.php @@ -41,6 +41,7 @@ use SilverStripe\ORM\ValidationResult; use SilverStripe\Security\Member; use SilverStripe\Security\Security; +use IanSimpson\OAuth2\AuthorizationValidators\BearerTokenValidatorEddsa; class OauthServerController extends Controller { @@ -290,7 +291,8 @@ public static function authenticateRequest($controller): ?ServerRequestInterface $server = new ResourceServer( new AccessTokenRepository(), - $publicKey + $publicKey, + new BearerTokenValidatorEddsa(new AccessTokenRepository()) ); $request = ServerRequest::fromGlobals(); @@ -331,7 +333,8 @@ public function validateClientGrant(HTTPRequest $request): HTTPResponse { $server = new ResourceServer( new AccessTokenRepository(), - $this->publicKey + $this->publicKey, + new BearerTokenValidatorEddsa(new AccessTokenRepository()) ); $this->handleRequest($request); diff --git a/code/Utility/Utility.php b/code/Utility/Utility.php new file mode 100644 index 0000000..3da2740 --- /dev/null +++ b/code/Utility/Utility.php @@ -0,0 +1,28 @@ +assertNotEmpty($m->ID); $configuration = Configuration::forSymmetricSigner( - new Sha256(), + new Eddsa(), InMemory::file($this->privateKey) ); @@ -287,7 +287,7 @@ public function testAccessTokenClientCredentials(): void $this->assertNotEmpty($c->ClientIdentifier); $configuration = Configuration::forSymmetricSigner( - new Sha256(), + new Eddsa(), InMemory::file($this->privateKey) ); diff --git a/tests/private.key b/tests/private.key index 14e8c11..cc3d4fa 100644 --- a/tests/private.key +++ b/tests/private.key @@ -1,27 +1,3 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEowIBAAKCAQEAvV8XeuqgS1J26dZDxpWBHowRUagkvoKb96zggk91MIK2Xat1 -zmV3OcdnsEDxkH1IVIAbiRSlvmet+qMhjHy7md3c1Wnbu7Pjgi/sOnd8zr9Ole1s -IUXLoPYps4xEBbF1sihXQ+zIk4vEaPe6DfQpHqaduDoIrw1VjrZ90p3iFXwhVrvW -keBQRAPF3NyHiLLBaSzFPl+m8GYSs/bBUAHucT1TDAr0XpmAoxHbM60OxcqETVRF -gnUaY1YhH0L1gEB5lgYtPQgGkJHiLmACSxzK7a7q61BPbtgMpb6Uee0CJ9ohJq+R -XC0DcS6ICfhxwqtyowzJEWAjrZbnFbvnAMSBkwIDAQABAoIBAACxiHR6x3t/IdBZ -aIYhpgdmm+mgpAHOmKOfjnLrt5Il6GUPa68F0Bn2EPskQb41tz4X+gHWgYTg/FIe -ptDM5CL8HQLrEFLvpbWkV0kfhuV67d6+r9avWr+MJzrxGI6sx7GPVEJ7a4Ce0mlP -/u5uJnhmQ82Y6M87TUTohi1vRpStjhKClcvgXZzV9lIqHlE/oOLuHwtBbnN3FeE6 -8B8uxLrbzCd72uIc+BU+1R5/qj7KKUoaiKdbdzrIT17MEow7uOClUUKPTspYsfLY -UcYNdcwh73XkWgIYthQcgcJB/ThIGED+ghHGILkywJctiryMEBd5LA62mlqnNw97 -Nb0h+wECgYEA9PoUkml1bzZJvInCAhCPBCkxEyTvXQIJEssfSDrCgXuiW2OGyIe+ -LJXYTrvB0dH7+AapjJBUtG2WE3hP3Ae+LQfGRMbUT2fHNo3McCa+gT1XkE8t7guJ -On21EILEU2ycwFS1inHrZI7iPcN/8QYyorVsjz2cd0v0vgWFaCOlr8kCgYEAxeR8 -RcyC5Anp3oXVvZx2y5Zf/W5DzT3Kf5Ki1LAIDVtxWo7oC9+ML45hsXLQ2DNfh7oO -LmUy+OUssLzgmSUFYX8bve0QJFQB0oSOpPH8UIX1p5gRa5ObbyVHhUh+krWSI+zl -sUfpn5pS6lmHhwqNdtKthr/cIoIXzJZTD5CerHsCgYEAp+CEnnz0g/zQ9Qn2UJHm -X/SWc8cY3UTa0O0vh8D0r+T4suX2ZI9ZTJZ3QCU8wjvDDwoJwJDb6zU55ifJmkAY -HCW6wkD7F1tH0mPD2ItEe658xiDsmbeAF/wzS5hBT+YbWVXzfmdo52VfNvI1SAd2 -RbhMJ7ER+1Kq6llAPchH77kCgYAhXnziHDE6GL3Z6wF2vqp9e/blujEuq6u4xVY1 -vRUug2vi1FQmpGR0JHMuw+iZfFgwf9wiUKg+tg5KIx9QU6DLpu5boVzmc0/3Wqjf -AXsFbQFWaFsPo81C+atMu0O0o29oJWs58KFha1lt8PjceZgPIElofnO1UfCHbBXH -eyB2fwKBgErOGtpA2kVTp5hThZSsiIc33LQqW6aI8JUSGiS3kReVHyoc88nn3n0Q -sNAKM6vDMqwGNmm6J9kTs2zrXXNvHOT+KxwL7/gWqXBvKm0defVGUywuSOxBTtSK -lJCkzL3aNfyoQfSAtavF43UzakTH3YViqV62tDeKPY/1HieeLm/1 ------END RSA PRIVATE KEY----- +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIO8ZlgnqG8D2Jnef4RhvTLeSYGN9FDXz1sl6cui+JYJT +-----END PRIVATE KEY----- diff --git a/tests/public.key b/tests/public.key index b788a63..44f6777 100644 --- a/tests/public.key +++ b/tests/public.key @@ -1,9 +1,3 @@ -----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvV8XeuqgS1J26dZDxpWB -HowRUagkvoKb96zggk91MIK2Xat1zmV3OcdnsEDxkH1IVIAbiRSlvmet+qMhjHy7 -md3c1Wnbu7Pjgi/sOnd8zr9Ole1sIUXLoPYps4xEBbF1sihXQ+zIk4vEaPe6DfQp -HqaduDoIrw1VjrZ90p3iFXwhVrvWkeBQRAPF3NyHiLLBaSzFPl+m8GYSs/bBUAHu -cT1TDAr0XpmAoxHbM60OxcqETVRFgnUaY1YhH0L1gEB5lgYtPQgGkJHiLmACSxzK -7a7q61BPbtgMpb6Uee0CJ9ohJq+RXC0DcS6ICfhxwqtyowzJEWAjrZbnFbvnAMSB -kwIDAQAB +MCowBQYDK2VwAyEAnMkew9Bl7oMb8191TUgu3L7CGSN8VmzUOc/6u03dtA0= -----END PUBLIC KEY----- From c10571ca49a827e9c4310c9345d62712ac2e1d0d Mon Sep 17 00:00:00 2001 From: Mike Nuguid Date: Wed, 22 Jan 2025 15:46:22 +1300 Subject: [PATCH 3/9] ENH Refactor code and ReadMe --- README.md | 13 +--- .../BearerTokenValidatorEddsa.php | 64 +++++++++---------- code/Entities/AccessTokenEntity.php | 12 ++-- code/Utility/Utility.php | 6 +- composer.json | 1 - 5 files changed, 46 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index db2e43a..bddb2d8 100644 --- a/README.md +++ b/README.md @@ -24,19 +24,10 @@ Install the add-on with Composer: composer require iansimpson/ss-oauth2-server ``` -Next, generate a private/public key pair: - -```sh -openssl genrsa -out private.key 2048 -openssl rsa -in private.key -pubout -out public.key -chmod 600 private.key -chmod 600 public.key -``` - -To generate ED25519 private/public key pair: +Next, generate a private/public key pair using Ed25519 algorithm: ```shell openssl genpkey -algorithm Ed25519 -out private.key -openssl pkey -in ed25519-private.key -pubout -out public.key +openssl pkey -in private.key -pubout -out public.key chmod 600 private.key chmod 600 public.key ``` diff --git a/code/AuthorizationValidators/BearerTokenValidatorEddsa.php b/code/AuthorizationValidators/BearerTokenValidatorEddsa.php index 0a7ec8b..34f5450 100644 --- a/code/AuthorizationValidators/BearerTokenValidatorEddsa.php +++ b/code/AuthorizationValidators/BearerTokenValidatorEddsa.php @@ -35,8 +35,7 @@ class BearerTokenValidatorEddsa extends BearerTokenValidator private $jwtConfiguration; /** - * @param AccessTokenRepositoryInterface $accessTokenRepository - * @param \DateInterval|null $jwtValidAtDateLeeway + * @inheritDoc */ public function __construct(AccessTokenRepositoryInterface $accessTokenRepository, \DateInterval $jwtValidAtDateLeeway = null) { @@ -44,11 +43,8 @@ public function __construct(AccessTokenRepositoryInterface $accessTokenRepositor $this->jwtValidAtDateLeeway = $jwtValidAtDateLeeway; } - /** - * Set the public key - * - * @param CryptKey $key + * @inheritDoc */ public function setPublicKey(CryptKey $key) { @@ -58,30 +54,7 @@ public function setPublicKey(CryptKey $key) } /** - * Initialise the JWT configuration. - */ - protected function initJwtConfiguration() - { - $this->jwtConfiguration = Configuration::forSymmetricSigner( - new Eddsa(), - InMemory::plainText('empty', 'empty') - ); - - $clock = new SystemClock(new DateTimeZone(\date_default_timezone_get())); - $publicKeyResource = $this->publicKey->getKeyContents(); // Ensure this contains the PEM key - $sodiumPublicKey = Utility::extractDERKeyValue($publicKeyResource); - - $this->jwtConfiguration->setValidationConstraints( - new LooseValidAt($clock, $this->jwtValidAtDateLeeway), - new SignedWith( - new Eddsa(), - InMemory::plainText($sodiumPublicKey, $this->publicKey->getPassPhrase() ?? '') - ) - ); - } - - /** - * {@inheritdoc} + * @inheritDoc */ public function validateAuthorization(ServerRequestInterface $request) { @@ -122,12 +95,37 @@ public function validateAuthorization(ServerRequestInterface $request) ->withAttribute('oauth_scopes', $claims->get('scopes')); } + /** + * Override this to use a different signer compatible with EdDSA algorithm + */ + protected function initJwtConfiguration() + { + $this->jwtConfiguration = Configuration::forSymmetricSigner( + new Eddsa(), + InMemory::plainText('empty', 'empty') + ); + + $clock = new SystemClock(new DateTimeZone(\date_default_timezone_get())); + + // Extract PEM formatted key from generated public key + $publicKeyResource = $this->publicKey->getKeyContents(); + + // Extract the public key in DER format + $derPublicKey = Utility::extractDERKeyValue($publicKeyResource); + + $this->jwtConfiguration->setValidationConstraints( + new LooseValidAt($clock, $this->jwtValidAtDateLeeway), + new SignedWith( + new Eddsa(), + InMemory::plainText($derPublicKey, $this->publicKey->getPassPhrase() ?? '') + ) + ); + } + /** * Convert single record arrays into strings to ensure backwards compatibility between v4 and v3.x of lcobucci/jwt * - * @param mixed $aud - * - * @return array|string + * @inheritDoc */ private function convertSingleRecordAudToString($aud) { diff --git a/code/Entities/AccessTokenEntity.php b/code/Entities/AccessTokenEntity.php index c85876f..ac58c15 100644 --- a/code/Entities/AccessTokenEntity.php +++ b/code/Entities/AccessTokenEntity.php @@ -96,10 +96,14 @@ class AccessTokenEntity extends DataObject implements AccessTokenEntityInterface */ public function convertToJWT() { - // Load the PEM-encoded private key from file (e.g., this can be $this->privateKey->getKeyContents()) - $pemPrivateKey = $this->privateKey->getKeyContents(); // Ensure this contains the PEM key - $sodiumPrivateKey = Utility::extractDERKeyValue($pemPrivateKey); - $secretkey = sodium_crypto_sign_secretkey(sodium_crypto_sign_seed_keypair($sodiumPrivateKey)); + // Extract the PEM key generated + $pemPrivateKey = $this->privateKey->getKeyContents(); + + // Extract the DER formatted key to use as seed for generating the private/secret key + $derKey = Utility::extractDERKeyValue($pemPrivateKey); + + // Generate the secret key via Sodium library + $secretkey = sodium_crypto_sign_secretkey(sodium_crypto_sign_seed_keypair($derKey)); // Configure the JWT generation $config = Configuration::forAsymmetricSigner( diff --git a/code/Utility/Utility.php b/code/Utility/Utility.php index 3da2740..2540857 100644 --- a/code/Utility/Utility.php +++ b/code/Utility/Utility.php @@ -19,8 +19,12 @@ class Utility * @param string $pemKey * @return string */ - public static function extractDERKeyValue(string $pemKey) + public static function extractDERKeyValue(string $pemKey): string { + if (empty($pemKey)) { + return ''; + } + $derKey = base64_decode(preg_replace('/-+.*?-+|\s/', '', $pemKey)); return substr($derKey, -32); diff --git a/composer.json b/composer.json index 95dcdec..4ad81ec 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,6 @@ "guzzlehttp/psr7": "^2.5", "league/oauth2-server": "^8.2", "monolog/monolog": "^1.2", - "phpseclib/phpseclib": "^3.0", "robbie/psr7-adapters": "^1", "silverstripe/framework": "^5", "silverstripe/siteconfig": "^5" From bbf68867bc0f640d8dacaa1f7e7f57f126f9003a Mon Sep 17 00:00:00 2001 From: James Zhu Date: Tue, 13 May 2025 16:51:07 +1200 Subject: [PATCH 4/9] Update monolog/monolog to v3 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 4ad81ec..6dbe246 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "php": "^8.1", "guzzlehttp/psr7": "^2.5", "league/oauth2-server": "^8.2", - "monolog/monolog": "^1.2", + "monolog/monolog": "^3", "robbie/psr7-adapters": "^1", "silverstripe/framework": "^5", "silverstripe/siteconfig": "^5" From e8683f05cfef6bc12ffd116200af0a6ecff80a10 Mon Sep 17 00:00:00 2001 From: James Zhu Date: Wed, 14 May 2025 13:53:31 +1200 Subject: [PATCH 5/9] Update Sever controller --- code/OauthServerController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/OauthServerController.php b/code/OauthServerController.php index 2f22ab2..ed07049 100644 --- a/code/OauthServerController.php +++ b/code/OauthServerController.php @@ -191,7 +191,7 @@ public static function getGrantTypeExpiryInterval(): string return self::config()->grant_expiry_interval ?? self::$grant_expiry_interval; } - public function handleRequest(HTTPRequest $request) + public function handleRequest(HTTPRequest $request): HTTPResponse { $this->myRequestAdapter = new HttpRequestAdapter(); $this->myRequest = $this->myRequestAdapter->toPsr7($request); From 00a500ac91a0a03a3df777c617c4500145d3da28 Mon Sep 17 00:00:00 2001 From: Mike Nuguid Date: Thu, 29 May 2025 21:52:13 +1200 Subject: [PATCH 6/9] Add private properties to avoid deprecation notice - Adding a value to dynamic properties is getting flagged by newer PHP versions e.g. 8.3, add this properties to the class itself since they are cannot be inherited from parent. --- code/AuthorizationValidators/BearerTokenValidatorEddsa.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/code/AuthorizationValidators/BearerTokenValidatorEddsa.php b/code/AuthorizationValidators/BearerTokenValidatorEddsa.php index 34f5450..0f0f562 100644 --- a/code/AuthorizationValidators/BearerTokenValidatorEddsa.php +++ b/code/AuthorizationValidators/BearerTokenValidatorEddsa.php @@ -34,6 +34,9 @@ class BearerTokenValidatorEddsa extends BearerTokenValidator */ private $jwtConfiguration; + private $accessTokenRepository; + private $jwtValidAtDateLeeway; + /** * @inheritDoc */ From 1fa13bd1ad2e5815b650f2bd755903606ff4d446 Mon Sep 17 00:00:00 2001 From: Mike Nuguid Date: Fri, 30 May 2025 14:07:55 +1200 Subject: [PATCH 7/9] FIX unit tests --- code/OauthServerController.php | 2 +- tests/OauthServerControllerTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/code/OauthServerController.php b/code/OauthServerController.php index ed07049..f659359 100644 --- a/code/OauthServerController.php +++ b/code/OauthServerController.php @@ -186,7 +186,7 @@ public function __construct() parent::__construct(); } - public static function getGrantTypeExpiryInterval(): string + public static function getGrantTypeExpiryInterval(): mixed { return self::config()->grant_expiry_interval ?? self::$grant_expiry_interval; } diff --git a/tests/OauthServerControllerTest.php b/tests/OauthServerControllerTest.php index af16fdc..ad7d2fd 100644 --- a/tests/OauthServerControllerTest.php +++ b/tests/OauthServerControllerTest.php @@ -322,7 +322,7 @@ public function now(): DateTimeImmutable public function testGetGrantTypeExpiryInterval(): void { $oauthController = OauthServerController::singleton(); - OauthServerController::config()->merge('grant_expiry_interval', 'PT1H'); - $this->assertSame('PT1H', $oauthController::getGrantTypeExpiryInterval()); + OauthServerController::config()->merge('grant_expiry_interval', ['PT1H']); + $this->assertSame('PT1H', $oauthController::getGrantTypeExpiryInterval()[0]); } } From d2dad78b2b1660cf642942f49a655d1fb30ae41c Mon Sep 17 00:00:00 2001 From: Mike Nuguid Date: Tue, 3 Jun 2025 15:17:07 +1200 Subject: [PATCH 8/9] Refactor code - use dependency injection to add new token validator for new algorithm - add extension points for generating token - remove unnecessary code from package --- .../BearerTokenValidatorEddsa.php | 137 ------------------ code/Entities/AccessTokenEntity.php | 59 ++++---- code/OauthServerController.php | 30 +++- code/Utility/Utility.php | 32 ---- 4 files changed, 48 insertions(+), 210 deletions(-) delete mode 100644 code/AuthorizationValidators/BearerTokenValidatorEddsa.php delete mode 100644 code/Utility/Utility.php diff --git a/code/AuthorizationValidators/BearerTokenValidatorEddsa.php b/code/AuthorizationValidators/BearerTokenValidatorEddsa.php deleted file mode 100644 index 0f0f562..0000000 --- a/code/AuthorizationValidators/BearerTokenValidatorEddsa.php +++ /dev/null @@ -1,137 +0,0 @@ -accessTokenRepository = $accessTokenRepository; - $this->jwtValidAtDateLeeway = $jwtValidAtDateLeeway; - } - - /** - * @inheritDoc - */ - public function setPublicKey(CryptKey $key) - { - $this->publicKey = $key; - - $this->initJwtConfiguration(); - } - - /** - * @inheritDoc - */ - public function validateAuthorization(ServerRequestInterface $request) - { - if ($request->hasHeader('authorization') === false) { - throw OAuthServerException::accessDenied('Missing "Authorization" header'); - } - - $header = $request->getHeader('authorization'); - $jwt = \trim((string) \preg_replace('/^\s*Bearer\s/', '', $header[0])); - - try { - // Attempt to parse the JWT - $token = $this->jwtConfiguration->parser()->parse($jwt); - } catch (\Lcobucci\JWT\Exception $exception) { - throw OAuthServerException::accessDenied($exception->getMessage(), null, $exception); - } - - try { - // Attempt to validate the JWT - $constraints = $this->jwtConfiguration->validationConstraints(); - $this->jwtConfiguration->validator()->assert($token, ...$constraints); - } catch (RequiredConstraintsViolated $exception) { - throw OAuthServerException::accessDenied('Access token could not be verified', null, $exception); - } - - $claims = $token->claims(); - - // Check if token has been revoked - if ($this->accessTokenRepository->isAccessTokenRevoked($claims->get('jti'))) { - throw OAuthServerException::accessDenied('Access token has been revoked'); - } - - // Return the request with additional attributes - return $request - ->withAttribute('oauth_access_token_id', $claims->get('jti')) - ->withAttribute('oauth_client_id', $this->convertSingleRecordAudToString($claims->get('aud'))) - ->withAttribute('oauth_user_id', $claims->get('sub')) - ->withAttribute('oauth_scopes', $claims->get('scopes')); - } - - /** - * Override this to use a different signer compatible with EdDSA algorithm - */ - protected function initJwtConfiguration() - { - $this->jwtConfiguration = Configuration::forSymmetricSigner( - new Eddsa(), - InMemory::plainText('empty', 'empty') - ); - - $clock = new SystemClock(new DateTimeZone(\date_default_timezone_get())); - - // Extract PEM formatted key from generated public key - $publicKeyResource = $this->publicKey->getKeyContents(); - - // Extract the public key in DER format - $derPublicKey = Utility::extractDERKeyValue($publicKeyResource); - - $this->jwtConfiguration->setValidationConstraints( - new LooseValidAt($clock, $this->jwtValidAtDateLeeway), - new SignedWith( - new Eddsa(), - InMemory::plainText($derPublicKey, $this->publicKey->getPassPhrase() ?? '') - ) - ); - } - - /** - * Convert single record arrays into strings to ensure backwards compatibility between v4 and v3.x of lcobucci/jwt - * - * @inheritDoc - */ - private function convertSingleRecordAudToString($aud) - { - return \is_array($aud) && \count($aud) === 1 ? $aud[0] : $aud; - } -} diff --git a/code/Entities/AccessTokenEntity.php b/code/Entities/AccessTokenEntity.php index ac58c15..806cd5c 100644 --- a/code/Entities/AccessTokenEntity.php +++ b/code/Entities/AccessTokenEntity.php @@ -11,10 +11,9 @@ use DateTimeImmutable; use Exception; use IanSimpson\OAuth2\OauthServerController; -use Lcobucci\JWT\Configuration; -use Lcobucci\JWT\Signer\Eddsa; -use Lcobucci\JWT\Signer\Key\InMemory; +use IanSimpson\OAuth2\Utility\Utility; use Lcobucci\JWT\Token; +use League\OAuth2\Server\CryptKey; use League\OAuth2\Server\Entities\AccessTokenEntityInterface; use League\OAuth2\Server\Entities\ClientEntityInterface; use League\OAuth2\Server\Entities\ScopeEntityInterface; @@ -24,9 +23,6 @@ use SilverStripe\ORM\DataObject; use SilverStripe\ORM\ManyManyList; use SilverStripe\Security\Member; -use Lcobucci\JWT\Builder; -use League\OAuth2\Server\CryptKey; -use IanSimpson\OAuth2\Utility\Utility; /** * @property ?string $Code @@ -40,7 +36,9 @@ */ class AccessTokenEntity extends DataObject implements AccessTokenEntityInterface { - use AccessTokenTrait; + use AccessTokenTrait { + convertToJWT as defaultConvertToJWT; + } use TokenEntityTrait; use EntityTrait; @@ -89,39 +87,32 @@ class AccessTokenEntity extends DataObject implements AccessTokenEntityInterface 'Code', ]; + /** + * TODO: Update to CryptkeyInterface once `league/oauth2-server` is updated to `^9` + */ + public function getPrivateKey(): ?CryptKey + { + return $this->privateKey; + } + /** * Generate a JWT from the access token * * @return Token */ - public function convertToJWT() + public function convertToJWT(): Token { - // Extract the PEM key generated - $pemPrivateKey = $this->privateKey->getKeyContents(); - - // Extract the DER formatted key to use as seed for generating the private/secret key - $derKey = Utility::extractDERKeyValue($pemPrivateKey); - - // Generate the secret key via Sodium library - $secretkey = sodium_crypto_sign_secretkey(sodium_crypto_sign_seed_keypair($derKey)); - - // Configure the JWT generation - $config = Configuration::forAsymmetricSigner( - new Eddsa(), - InMemory::plainText($secretkey), - InMemory::plainText('empty', 'empty') - ); - - // return the token - return $config->builder() - ->permittedFor($this->getClient()->getIdentifier()) - ->identifiedBy($this->getIdentifier()) - ->issuedAt(new DateTimeImmutable()) - ->canOnlyBeUsedAfter(new DateTimeImmutable()) - ->expiresAt($this->getExpiryDateTime()) - ->relatedTo((string) $this->getUserIdentifier()) - ->withClaim('scopes', $this->getScopes()) - ->getToken($config->signer(), $config->signingKey()); + $token = null; + + // Get token from extension (in case of different implementation than the default) + $this->extend('updateJWT', $token); + + if ($token) { + return $token; + } + + // Default token generated + return $this->defaultConvertToJWT(); } public function getIdentifier(): string diff --git a/code/OauthServerController.php b/code/OauthServerController.php index f659359..63bf36e 100644 --- a/code/OauthServerController.php +++ b/code/OauthServerController.php @@ -12,15 +12,17 @@ use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\ServerRequest; use GuzzleHttp\Psr7\Utils; -use IanSimpson\OAuth2\Entities\UserEntity; +use IanSimpson\OAuth2\AuthorizationValidators\BearerTokenValidatorEddsa; use IanSimpson\OAuth2\Entities\ClientEntity; use IanSimpson\OAuth2\Entities\ScopeEntity; +use IanSimpson\OAuth2\Entities\UserEntity; use IanSimpson\OAuth2\Repositories\AccessTokenRepository; use IanSimpson\OAuth2\Repositories\AuthCodeRepository; use IanSimpson\OAuth2\Repositories\ClientRepository; use IanSimpson\OAuth2\Repositories\RefreshTokenRepository; use IanSimpson\OAuth2\Repositories\ScopeRepository; use League\OAuth2\Server\AuthorizationServer; +use League\OAuth2\Server\AuthorizationValidators\AuthorizationValidatorInterface; use League\OAuth2\Server\Entities\ScopeEntityInterface; use League\OAuth2\Server\Exception\OAuthServerException; use League\OAuth2\Server\Grant\AuthCodeGrant; @@ -41,7 +43,6 @@ use SilverStripe\ORM\ValidationResult; use SilverStripe\Security\Member; use SilverStripe\Security\Security; -use IanSimpson\OAuth2\AuthorizationValidators\BearerTokenValidatorEddsa; class OauthServerController extends Controller { @@ -72,6 +73,11 @@ class OauthServerController extends Controller */ protected $logger; + /** + * @var null|AuthorizationValidatorInterface + */ + protected $authorizationValidator = null; + private readonly string $privateKey; private readonly string $publicKey; @@ -191,6 +197,16 @@ public static function getGrantTypeExpiryInterval(): mixed return self::config()->grant_expiry_interval ?? self::$grant_expiry_interval; } + public function getAuthorizationValidator(): ?AuthorizationValidatorInterface + { + return $this->authorizationValidator; + } + + public function setAuthorizationValidator(?AuthorizationValidatorInterface $value): void + { + $this->authorizationValidator = $value; + } + public function handleRequest(HTTPRequest $request): HTTPResponse { $this->myRequestAdapter = new HttpRequestAdapter(); @@ -285,14 +301,14 @@ public function accessToken(): HTTPResponse /** * @param mixed $controller */ - public static function authenticateRequest($controller): ?ServerRequestInterface + public function authenticateRequest($controller): ?ServerRequestInterface { $publicKey = self::getKey('OAUTH_PUBLIC_KEY_PATH'); $server = new ResourceServer( new AccessTokenRepository(), $publicKey, - new BearerTokenValidatorEddsa(new AccessTokenRepository()) + $this->getAuthorizationValidator() ); $request = ServerRequest::fromGlobals(); @@ -313,9 +329,9 @@ public static function authenticateRequest($controller): ?ServerRequestInterface /** * @param mixed $controller */ - public static function getMember($controller): ?Member + public function getMember($controller): ?Member { - $request = self::authenticateRequest($controller); + $request = $this->authenticateRequest($controller); if (!$request instanceof ServerRequestInterface) { return null; @@ -334,7 +350,7 @@ public function validateClientGrant(HTTPRequest $request): HTTPResponse $server = new ResourceServer( new AccessTokenRepository(), $this->publicKey, - new BearerTokenValidatorEddsa(new AccessTokenRepository()) + $this->getAuthorizationValidator() ); $this->handleRequest($request); diff --git a/code/Utility/Utility.php b/code/Utility/Utility.php deleted file mode 100644 index 2540857..0000000 --- a/code/Utility/Utility.php +++ /dev/null @@ -1,32 +0,0 @@ - Date: Tue, 3 Jun 2025 16:24:35 +1200 Subject: [PATCH 9/9] Add docs and revert unit tests - add docs for configuring EdDSA/Ed25519 access token - revert previously updated unit tests - add unit tests for new API --- README.md | 9 ++-- code/Entities/AccessTokenEntity.php | 1 - docs/eddsa.md | 45 +++++++++++++++++++ tests/AccessTokenEntityTest.php | 35 +++++++++++++++ tests/OauthServerControllerTest.php | 19 ++++++-- .../AccessTokenEntityExtensionFake.php | 21 +++++++++ tests/fixtures/BearerTokenValidatorFake.php | 19 ++++++++ tests/private.key | 30 +++++++++++-- tests/public.key | 8 +++- 9 files changed, 174 insertions(+), 13 deletions(-) create mode 100644 docs/eddsa.md create mode 100644 tests/AccessTokenEntityTest.php create mode 100644 tests/fixtures/AccessTokenEntityExtensionFake.php create mode 100644 tests/fixtures/BearerTokenValidatorFake.php diff --git a/README.md b/README.md index bddb2d8..8cec37d 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,11 @@ Install the add-on with Composer: composer require iansimpson/ss-oauth2-server ``` -Next, generate a private/public key pair using Ed25519 algorithm: -```shell -openssl genpkey -algorithm Ed25519 -out private.key -openssl pkey -in private.key -pubout -out public.key +Next, generate a private/public key pair: + +```sh +openssl genrsa -out private.key 2048 +openssl rsa -in private.key -pubout -out public.key chmod 600 private.key chmod 600 public.key ``` diff --git a/code/Entities/AccessTokenEntity.php b/code/Entities/AccessTokenEntity.php index 806cd5c..0fa5535 100644 --- a/code/Entities/AccessTokenEntity.php +++ b/code/Entities/AccessTokenEntity.php @@ -11,7 +11,6 @@ use DateTimeImmutable; use Exception; use IanSimpson\OAuth2\OauthServerController; -use IanSimpson\OAuth2\Utility\Utility; use Lcobucci\JWT\Token; use League\OAuth2\Server\CryptKey; use League\OAuth2\Server\Entities\AccessTokenEntityInterface; diff --git a/docs/eddsa.md b/docs/eddsa.md new file mode 100644 index 0000000..92a7868 --- /dev/null +++ b/docs/eddsa.md @@ -0,0 +1,45 @@ +# EdDSA + +One of the latest digital signature scheme that is deemed faster, more secure and simpler to implement compare with its counterpart e.g. RSA. + +## Ed25519 + +Specific implemenation of EdDSA. You may need to use + +## Steps + +### Generate a private/public key pair using Ed25519 algorithm + +```shell +openssl genpkey -algorithm Ed25519 -out private.key +openssl pkey -in private.key -pubout -out public.key +``` + +### Add authorization validator + +You may extend the [Bearer token validator](https://github.com/thephpleague/oauth2-server/blob/master/src/AuthorizationValidators/BearerTokenValidator.php) if majority of the logic is almost identical or a totally different implementation as long as it implements `AuthorizationValidatorInterface`. + +### Add yaml config + +Add a custom yaml config on your project to set the custom authorization validator as `OauthServerController` dependency. You may need to pass other constructor parameters if needed. + +```yaml +--- +Name: oauth_server_controller_dependencies +--- +IanSimpson\OAuth2\Entities\AccessTokenEntity: + extensions: + - App\DAM\OauthServer\AccessTokenEntityExtension + +SilverStripe\Core\Injector\Injector: + App\DAM\OauthServer\BearerTokenValidatorEddsa: + constructor: + - '%$IanSimpson\OAuth2\Repositories\AccessTokenRepository' + IanSimpson\OAuth2\OauthServerController: + properties: + AuthorizationValidator: '%$App\DAM\OauthServer\BearerTokenValidatorEddsa' +``` + +### Update JWT Token + +A public method is added to allow updating the generated access token in case the chosen algorithm does not match the token generator. To do so, add an extension to the `AccessTokenEntity` in your project and use the `updateJWT` hook. You may also refer to the [default token generator](https://github.com/thephpleague/oauth2-server/blob/master/src/Entities/Traits/AccessTokenTrait.php#L60), on the token format. diff --git a/tests/AccessTokenEntityTest.php b/tests/AccessTokenEntityTest.php new file mode 100644 index 0000000..6937b35 --- /dev/null +++ b/tests/AccessTokenEntityTest.php @@ -0,0 +1,35 @@ + [ + AccessTokenEntityExtensionFake::class, + ] + ]; + + public function testGetPrivateKey(): void + { + $cryptKeyFake = $this->createMock(CryptKey::class); + $entity = AccessTokenEntity::create(); + + $this->assertNull($entity->getPrivateKey()); + + $entity->setPrivateKey($cryptKeyFake); + $this->assertEquals($cryptKeyFake, $entity->getPrivateKey()); + } + + public function testConvertToJWT(): void + { + $entity = AccessTokenEntity::create(); + $this->assertInstanceOf(Plain::class, $entity->convertToJWT()); + } +} diff --git a/tests/OauthServerControllerTest.php b/tests/OauthServerControllerTest.php index ad7d2fd..a4ab078 100644 --- a/tests/OauthServerControllerTest.php +++ b/tests/OauthServerControllerTest.php @@ -12,11 +12,12 @@ use IanSimpson\OAuth2\OauthServerController; use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Signer\Key\InMemory; -use Lcobucci\JWT\Signer\Eddsa; +use Lcobucci\JWT\Signer\Rsa\Sha256; use Lcobucci\JWT\Validation\Constraint\IdentifiedBy; use Lcobucci\JWT\Validation\Constraint\LooseValidAt; use Lcobucci\JWT\Validation\Constraint\PermittedFor; use Lcobucci\JWT\Validation\Constraint\RelatedTo; +use League\OAuth2\Server\AuthorizationValidators\AuthorizationValidatorInterface; use League\OAuth2\Server\CryptKey; use League\OAuth2\Server\CryptTrait; use Monolog\Logger; @@ -28,6 +29,7 @@ use SilverStripe\Core\Injector\Injector; use SilverStripe\Dev\FunctionalTest; use SilverStripe\Security\Member; +use IanSimpson\Tests\Fixtures\BearerTokenValidatorFake; /** * @internal @@ -212,7 +214,7 @@ public function testAccessTokenUserID(): void $this->assertNotEmpty($m->ID); $configuration = Configuration::forSymmetricSigner( - new Eddsa(), + new Sha256(), InMemory::file($this->privateKey) ); @@ -241,7 +243,7 @@ public function now(): DateTimeImmutable $_SERVER['AUTHORIZATION'] = sprintf('Bearer %s', $at->__toString()); - $request = OauthServerController::authenticateRequest(null); + $request = OauthServerController::singleton()->authenticateRequest(null); $this->assertInstanceOf(ServerRequestInterface::class, $request); $this->assertSame($request->getAttribute('oauth_access_token_id'), $at->Code); @@ -287,7 +289,7 @@ public function testAccessTokenClientCredentials(): void $this->assertNotEmpty($c->ClientIdentifier); $configuration = Configuration::forSymmetricSigner( - new Eddsa(), + new Sha256(), InMemory::file($this->privateKey) ); @@ -325,4 +327,13 @@ public function testGetGrantTypeExpiryInterval(): void OauthServerController::config()->merge('grant_expiry_interval', ['PT1H']); $this->assertSame('PT1H', $oauthController::getGrantTypeExpiryInterval()[0]); } + + public function testGetAuthorizationValidator(): void + { + $oauthController = OauthServerController::singleton(); + $this->assertNull($oauthController->getAuthorizationValidator()); + + $oauthController->setAuthorizationValidator(new BearerTokenValidatorFake()); + $this->assertInstanceOf(BearerTokenValidatorFake::class, $oauthController->getAuthorizationValidator()); + } } diff --git a/tests/fixtures/AccessTokenEntityExtensionFake.php b/tests/fixtures/AccessTokenEntityExtensionFake.php new file mode 100644 index 0000000..6c48e75 --- /dev/null +++ b/tests/fixtures/AccessTokenEntityExtensionFake.php @@ -0,0 +1,21 @@ + 'none'], 'headers'); + $claims = new DataSet([], 'claims'); + $signature = new Signature('hash', 'signature'); + $token = new Plain($headers, $claims, $signature); + } +} diff --git a/tests/fixtures/BearerTokenValidatorFake.php b/tests/fixtures/BearerTokenValidatorFake.php new file mode 100644 index 0000000..c275d8e --- /dev/null +++ b/tests/fixtures/BearerTokenValidatorFake.php @@ -0,0 +1,19 @@ +