diff --git a/code/Entities/AccessTokenEntity.php b/code/Entities/AccessTokenEntity.php index 7a015d0..0fa5535 100644 --- a/code/Entities/AccessTokenEntity.php +++ b/code/Entities/AccessTokenEntity.php @@ -11,6 +11,8 @@ use DateTimeImmutable; use Exception; use IanSimpson\OAuth2\OauthServerController; +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; @@ -33,7 +35,9 @@ */ class AccessTokenEntity extends DataObject implements AccessTokenEntityInterface { - use AccessTokenTrait; + use AccessTokenTrait { + convertToJWT as defaultConvertToJWT; + } use TokenEntityTrait; use EntityTrait; @@ -82,6 +86,34 @@ 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(): Token + { + $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 { return (string) $this->Code; diff --git a/code/OauthServerController.php b/code/OauthServerController.php index 223cc09..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; @@ -71,6 +73,11 @@ class OauthServerController extends Controller */ protected $logger; + /** + * @var null|AuthorizationValidatorInterface + */ + protected $authorizationValidator = null; + private readonly string $privateKey; private readonly string $publicKey; @@ -185,12 +192,22 @@ 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; } - public function handleRequest(HTTPRequest $request) + 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(); $this->myRequest = $this->myRequestAdapter->toPsr7($request); @@ -284,13 +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 + $publicKey, + $this->getAuthorizationValidator() ); $request = ServerRequest::fromGlobals(); @@ -311,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; @@ -331,7 +349,8 @@ public function validateClientGrant(HTTPRequest $request): HTTPResponse { $server = new ResourceServer( new AccessTokenRepository(), - $this->publicKey + $this->publicKey, + $this->getAuthorizationValidator() ); $this->handleRequest($request); diff --git a/composer.json b/composer.json index 14b296d..6dbe246 100644 --- a/composer.json +++ b/composer.json @@ -21,10 +21,10 @@ "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": "^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, 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 e6dae7a..a4ab078 100644 --- a/tests/OauthServerControllerTest.php +++ b/tests/OauthServerControllerTest.php @@ -17,6 +17,7 @@ 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 @@ -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); @@ -322,7 +324,16 @@ 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]); + } + + 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 @@ +