diff --git a/README.md b/README.md index 8cec37d..bddb2d8 100644 --- a/README.md +++ b/README.md @@ -24,11 +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 +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 chmod 600 private.key chmod 600 public.key ``` diff --git a/code/AuthorizationValidators/BearerTokenValidatorEddsa.php b/code/AuthorizationValidators/BearerTokenValidatorEddsa.php new file mode 100644 index 0000000..34f5450 --- /dev/null +++ b/code/AuthorizationValidators/BearerTokenValidatorEddsa.php @@ -0,0 +1,134 @@ +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 7a015d0..ac58c15 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,41 @@ class AccessTokenEntity extends DataObject implements AccessTokenEntityInterface 'Code', ]; + /** + * Generate a JWT from the access token + * + * @return Token + */ + public function convertToJWT() + { + // 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()); + } + 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..2540857 --- /dev/null +++ b/code/Utility/Utility.php @@ -0,0 +1,32 @@ +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-----