diff --git a/.docs/README.md b/.docs/README.md index 4743960..575f7e1 100644 --- a/.docs/README.md +++ b/.docs/README.md @@ -40,16 +40,51 @@ oauth2.server: permissionCheck: true publicKey: path: "/path/to/public.key" - passPhrase: permissionCheck: true grants: - authCode: true - clientCredentials: true - implicit: true - password: true - refreshToken: true + authCode: + ttl: PT1H + clientCredentials: + ttl: PT1H + implicit: + ttl: PT1H + password: + ttl: PT1H + refreshToken: + ttl: P7D ``` +### Grant Configuration + +Each grant type accepts an object with options. Use empty object `[]` to enable with defaults, or `false` to disable. + +**Common option:** +- `ttl` - Access token lifetime (ISO 8601 duration) + +**authCode grant:** +- `authCodeTTL` - Authorization code lifetime (default: `PT10M`) +- `codeExchangeProof` - Enable PKCE (default: `false`) + +```neon +grants: + authCode: + ttl: PT1H + authCodeTTL: PT5M + codeExchangeProof: true +``` + +**implicit grant:** +- `accessTokenTTL` - Access token TTL for grant construction (default: `PT10M`) + +```neon +grants: + implicit: + ttl: PT2H + accessTokenTTL: PT15M +``` + +**TTL format** uses [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations): `PT10M` (10 min), `PT1H` (1 hour), `P1D` (1 day), `P7D` (7 days) + For encryption key, you can use `Defuse\Crypt\Key::loadFromAsciiSafeString($string)` or key in a string form. ```neon diff --git a/.github/workflows/codesniffer.yml b/.github/workflows/codesniffer.yml index dfc76ff..d5ff803 100644 --- a/.github/workflows/codesniffer.yml +++ b/.github/workflows/codesniffer.yml @@ -2,6 +2,7 @@ name: "Codesniffer" on: pull_request: + workflow_dispatch: push: branches: ["*"] @@ -12,4 +13,6 @@ on: jobs: codesniffer: name: "Codesniffer" - uses: contributte/.github/.github/workflows/codesniffer.yml@v1 + uses: contributte/.github/.github/workflows/codesniffer.yml@master + with: + php: "8.3" diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..2394f16 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,18 @@ +name: "Coverage" + +on: + pull_request: + workflow_dispatch: + + push: + branches: ["*"] + + schedule: + - cron: "0 8 * * 1" + +jobs: + coverage: + name: "Coverage" + uses: contributte/.github/.github/workflows/nette-tester-coverage-v2.yml@master + with: + php: "8.3" diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index db3ad34..9827fdd 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -2,6 +2,7 @@ name: "Phpstan" on: pull_request: + workflow_dispatch: push: branches: ["*"] @@ -12,4 +13,6 @@ on: jobs: phpstan: name: "Phpstan" - uses: contributte/.github/.github/workflows/phpstan.yml@v1 + uses: contributte/.github/.github/workflows/phpstan.yml@master + with: + php: "8.3" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ab22ea5..d71cdae 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,36 +1,37 @@ -name: "Nette Tester" +name: "Tests" on: pull_request: + workflow_dispatch: push: - branches: [ "*" ] + branches: ["*"] schedule: - cron: "0 8 * * 1" jobs: - test82: - name: "Nette Tester" - uses: contributte/.github/.github/workflows/nette-tester.yml@v1 + test84: + name: "Tests" + uses: contributte/.github/.github/workflows/nette-tester.yml@master with: - php: "8.2" + php: "8.4" - test81: - name: "Nette Tester" - uses: contributte/.github/.github/workflows/nette-tester.yml@v1 + test83: + name: "Tests" + uses: contributte/.github/.github/workflows/nette-tester.yml@master with: - php: "8.1" + php: "8.3" - test80: - name: "Nette Tester" - uses: contributte/.github/.github/workflows/nette-tester.yml@v1 + test82: + name: "Tests" + uses: contributte/.github/.github/workflows/nette-tester.yml@master with: - php: "8.0" + php: "8.2" testlower: - name: "Nette Tester" - uses: contributte/.github/.github/workflows/nette-tester.yml@v1 + name: "Tests" + uses: contributte/.github/.github/workflows/nette-tester.yml@master with: - php: "8.0" + php: "8.2" composer: "composer update --no-interaction --no-progress --prefer-dist --prefer-stable --prefer-lowest" diff --git a/Makefile b/Makefile index af542a9..d53bea0 100644 --- a/Makefile +++ b/Makefile @@ -7,13 +7,13 @@ qa: phpstan cs cs: ifdef GITHUB_ACTION - vendor/bin/phpcs --standard=ruleset.xml --encoding=utf-8 --colors -nsp -q --report=checkstyle src tests | cs2pr + vendor/bin/phpcs --standard=ruleset.xml --encoding=utf-8 --colors -nsp --extensions=php,phpt -q --report=checkstyle src tests | cs2pr else - vendor/bin/phpcs --standard=ruleset.xml --encoding=utf-8 --colors -nsp src tests + vendor/bin/phpcs --standard=ruleset.xml --encoding=utf-8 --colors -nsp --extensions=php,phpt src tests endif csf: - vendor/bin/phpcbf --standard=ruleset.xml --encoding=utf-8 --colors -nsp src tests + vendor/bin/phpcbf --standard=ruleset.xml --encoding=utf-8 --colors -nsp --extensions=php,phpt src tests phpstan: vendor/bin/phpstan analyse -c phpstan.neon diff --git a/composer.json b/composer.json index 9a83331..b7f74ad 100644 --- a/composer.json +++ b/composer.json @@ -11,19 +11,16 @@ "type": "library", "license": "MIT", "require": { - "php": ">=8.1", + "php": ">=8.2", "league/oauth2-server": "^9.1.0", - "nette/di": "^3.0.14" + "nette/di": "^3.2.2" }, "require-dev": { - "contributte/psr7-http-message": "^0.9", "contributte/phpstan": "^0.2.0", - "contributte/qa": "^0.4", + "contributte/psr7-http-message": "^0.10.0", + "contributte/qa": "^0.4.0", "contributte/tester": "^0.3.0", - "nette/application": "^3.1.8" - }, - "conflict": { - "nette/utils": "<3.2.5" + "nette/application": "^3.2.0" }, "autoload": { "psr-4": { @@ -32,7 +29,7 @@ }, "autoload-dev": { "psr-4": { - "Tests\\Fixtures\\": "tests/fixtures" + "Tests\\": "tests" } }, "minimum-stability": "dev", @@ -45,7 +42,7 @@ }, "extra": { "branch-alias": { - "dev-master": "0.5.x-dev" + "dev-master": "0.6.x-dev" } } } diff --git a/phpstan.neon b/phpstan.neon index 3bce13c..16b3197 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,7 +3,7 @@ includes: parameters: level: 9 - phpVersion: 80100 + phpVersion: 80200 scanDirectories: - src @@ -13,5 +13,6 @@ parameters: paths: - src + - .docs ignoreErrors: diff --git a/ruleset.xml b/ruleset.xml index d34e3d3..ea7ee16 100644 --- a/ruleset.xml +++ b/ruleset.xml @@ -1,19 +1,18 @@ - + - + - - + - + /tests/tmp diff --git a/src/DI/OAuth2ServerExtension.php b/src/DI/OAuth2ServerExtension.php index 2a4fc22..00cb0ac 100644 --- a/src/DI/OAuth2ServerExtension.php +++ b/src/DI/OAuth2ServerExtension.php @@ -42,22 +42,50 @@ public function getConfigSchema(): Schema { return Expect::structure([ 'encryptionKey' => Expect::anyOf(Expect::string(), Expect::type(Statement::class)), - 'privateKey' => Expect::array([ - 'path' => Expect::string(), - 'passPhrase' => Expect::string(), + 'privateKey' => Expect::structure([ + 'path' => Expect::string()->required(), + 'passPhrase' => Expect::string()->nullable(), 'permissionCheck' => Expect::bool(true), - ]), - 'publicKey' => Expect::array([ - 'path' => Expect::string(), + ])->castTo('array'), + 'publicKey' => Expect::structure([ + 'path' => Expect::string()->required(), 'permissionCheck' => Expect::bool(true), - ]), - 'grants' => Expect::array([ - self::GRANT_AUTH_CODE => Expect::bool(false), - self::GRANT_CLIENT_CREDENTIALS => Expect::bool(false), - self::GRANT_IMPLICIT => Expect::bool(false), - self::GRANT_PASSWORD => Expect::bool(false), - self::GRANT_REFRESH_TOKEN => Expect::bool(false), - ]), + ])->castTo('array'), + 'grants' => Expect::structure([ + self::GRANT_AUTH_CODE => Expect::anyOf( + false, + Expect::structure([ + 'ttl' => Expect::anyOf(Expect::string(), Expect::type(Statement::class))->nullable(), + 'authCodeTTL' => Expect::anyOf(Expect::string(), Expect::type(Statement::class))->nullable(), + 'codeExchangeProof' => Expect::bool(false), + ])->castTo('array'), + )->default(false), + self::GRANT_CLIENT_CREDENTIALS => Expect::anyOf( + false, + Expect::structure([ + 'ttl' => Expect::anyOf(Expect::string(), Expect::type(Statement::class))->nullable(), + ])->castTo('array'), + )->default(false), + self::GRANT_IMPLICIT => Expect::anyOf( + false, + Expect::structure([ + 'ttl' => Expect::anyOf(Expect::string(), Expect::type(Statement::class))->nullable(), + 'accessTokenTTL' => Expect::anyOf(Expect::string(), Expect::type(Statement::class))->nullable(), + ])->castTo('array'), + )->default(false), + self::GRANT_PASSWORD => Expect::anyOf( + false, + Expect::structure([ + 'ttl' => Expect::anyOf(Expect::string(), Expect::type(Statement::class))->nullable(), + ])->castTo('array'), + )->default(false), + self::GRANT_REFRESH_TOKEN => Expect::anyOf( + false, + Expect::structure([ + 'ttl' => Expect::anyOf(Expect::string(), Expect::type(Statement::class))->nullable(), + ])->castTo('array'), + )->default(false), + ])->castTo('array'), 'responseType' => Expect::type(Statement::class), ]); } @@ -94,11 +122,11 @@ public function loadConfiguration(): void switch ($grant) { case self::GRANT_AUTH_CODE: - if (isset($options->codeExchangeProof) && $options->codeExchangeProof === true) { + if (isset($options['codeExchangeProof']) && $options['codeExchangeProof'] === true) { $grantDefinition->addSetup('enableCodeExchangeProof'); } - $ttl = isset($options->authCodeTTL) && $options->authCodeTTL !== false ? $options->authCodeTTL : 'PT10M'; + $ttl = $options['authCodeTTL'] ?? 'PT10M'; if (!$ttl instanceof Statement) { $ttl = new Statement(DateInterval::class, [$ttl]); @@ -112,7 +140,7 @@ public function loadConfiguration(): void break; case self::GRANT_IMPLICIT: - $ttl = isset($options->accessTokenTTL) && $options->accessTokenTTL !== false ? $options->accessTokenTTL : 'PT10M'; + $ttl = $options['accessTokenTTL'] ?? 'PT10M'; if (!$ttl instanceof Statement) { $ttl = new Statement(DateInterval::class, [$ttl]); @@ -137,12 +165,12 @@ public function loadConfiguration(): void )); } - $ttl = $options->ttl ?? null; + $ttl = $options['ttl'] ?? null; if (!$ttl instanceof Statement && $ttl !== null) { $ttl = new Statement(DateInterval::class, [$ttl]); } - $authServer->addSetup('revokeRefreshTokens', [false]); + $authServer->addSetup('revokeRefreshTokens', [false]); $authServer->addSetup('enableGrantType', [$grantDefinition, $ttl]); } } diff --git a/tests/Cases/DI/OAuth2ServerExtensionTest.php b/tests/Cases/DI/OAuth2ServerExtensionTest.php index 4dc5e90..eda23e7 100644 --- a/tests/Cases/DI/OAuth2ServerExtensionTest.php +++ b/tests/Cases/DI/OAuth2ServerExtensionTest.php @@ -6,6 +6,11 @@ use Contributte\Tester\Environment; use Contributte\Tester\Toolkit; use League\OAuth2\Server\AuthorizationServer; +use League\OAuth2\Server\Grant\AuthCodeGrant; +use League\OAuth2\Server\Grant\ClientCredentialsGrant; +use League\OAuth2\Server\Grant\ImplicitGrant; +use League\OAuth2\Server\Grant\PasswordGrant; +use League\OAuth2\Server\Grant\RefreshTokenGrant; use League\OAuth2\Server\ResourceServer; use Nette\DI\Compiler; use Nette\DI\Container; @@ -15,6 +20,7 @@ require_once __DIR__ . '/../../bootstrap.php'; +// Test basic configuration without grants Toolkit::test(function (): void { $loader = new ContainerLoader(Environment::getTmpDir(), true); $class = $loader->load(function (Compiler $compiler): void { @@ -23,11 +29,11 @@ oauth2.server: encryptionKey: "fake" privateKey: - path: "../../fixtures/keys/private.key" + path: "../../Fixtures/keys/private.key" passPhrase: "foo" permissionCheck: false publicKey: - path: "../../fixtures/keys/public.key" + path: "../../Fixtures/keys/public.key" permissionCheck: false services: @@ -47,6 +53,7 @@ Assert::count(1, $container->findByType(ResourceServer::class)); }); +// Test encryption key as Statement Toolkit::test(function (): void { $loader = new ContainerLoader(Environment::getTmpDir(), true); $class = $loader->load(function (Compiler $compiler): void { @@ -55,11 +62,11 @@ oauth2.server: encryptionKey: Defuse\Crypto\Key::loadFromAsciiSafeString("fake") privateKey: - path: "../../fixtures/keys/private.key" + path: "../../Fixtures/keys/private.key" passPhrase: "foo" permissionCheck: false publicKey: - path: "../../fixtures/keys/public.key" + path: "../../Fixtures/keys/public.key" permissionCheck: false services: @@ -78,3 +85,159 @@ Assert::count(1, $container->findByType(AuthorizationServer::class)); Assert::count(1, $container->findByType(ResourceServer::class)); }); + +// Test grants with default TTL +Toolkit::test(function (): void { + $loader = new ContainerLoader(Environment::getTmpDir(), true); + $class = $loader->load(function (Compiler $compiler): void { + $compiler->addExtension('oauth2.server', new OAuth2ServerExtension()); + $compiler->loadConfig(FileMock::create(' + oauth2.server: + encryptionKey: "fake" + privateKey: + path: "../../Fixtures/keys/private.key" + passPhrase: "foo" + permissionCheck: false + publicKey: + path: "../../Fixtures/keys/public.key" + permissionCheck: false + grants: + clientCredentials: [] + password: [] + refreshToken: [] + + services: + - Tests\Fixtures\Repositories\ClientRepository + - Tests\Fixtures\Repositories\AccessTokenRepository + - Tests\Fixtures\Repositories\ScopeRepository + - Tests\Fixtures\Repositories\AuthCodeRepository + - Tests\Fixtures\Repositories\RefreshTokenRepository + - Tests\Fixtures\Repositories\UserRepository + ', 'neon')); + }, [getmypid(), 3]); + + /** @var Container $container */ + $container = new $class(); + + Assert::count(1, $container->findByType(AuthorizationServer::class)); + Assert::count(1, $container->findByType(ClientCredentialsGrant::class)); + Assert::count(1, $container->findByType(PasswordGrant::class)); + Assert::count(1, $container->findByType(RefreshTokenGrant::class)); +}); + +// Test grants with custom TTL (issue #14) +Toolkit::test(function (): void { + $loader = new ContainerLoader(Environment::getTmpDir(), true); + $class = $loader->load(function (Compiler $compiler): void { + $compiler->addExtension('oauth2.server', new OAuth2ServerExtension()); + $compiler->loadConfig(FileMock::create(' + oauth2.server: + encryptionKey: "fake" + privateKey: + path: "../../Fixtures/keys/private.key" + passPhrase: "foo" + permissionCheck: false + publicKey: + path: "../../Fixtures/keys/public.key" + permissionCheck: false + grants: + clientCredentials: + ttl: PT30M + password: + ttl: PT1H + refreshToken: + ttl: P7D + + services: + - Tests\Fixtures\Repositories\ClientRepository + - Tests\Fixtures\Repositories\AccessTokenRepository + - Tests\Fixtures\Repositories\ScopeRepository + - Tests\Fixtures\Repositories\AuthCodeRepository + - Tests\Fixtures\Repositories\RefreshTokenRepository + - Tests\Fixtures\Repositories\UserRepository + ', 'neon')); + }, [getmypid(), 4]); + + /** @var Container $container */ + $container = new $class(); + + Assert::count(1, $container->findByType(AuthorizationServer::class)); + Assert::count(1, $container->findByType(ClientCredentialsGrant::class)); + Assert::count(1, $container->findByType(PasswordGrant::class)); + Assert::count(1, $container->findByType(RefreshTokenGrant::class)); +}); + +// Test authCode grant with all options +Toolkit::test(function (): void { + $loader = new ContainerLoader(Environment::getTmpDir(), true); + $class = $loader->load(function (Compiler $compiler): void { + $compiler->addExtension('oauth2.server', new OAuth2ServerExtension()); + $compiler->loadConfig(FileMock::create(' + oauth2.server: + encryptionKey: "fake" + privateKey: + path: "../../Fixtures/keys/private.key" + passPhrase: "foo" + permissionCheck: false + publicKey: + path: "../../Fixtures/keys/public.key" + permissionCheck: false + grants: + authCode: + ttl: PT1H + authCodeTTL: PT5M + codeExchangeProof: true + + services: + - Tests\Fixtures\Repositories\ClientRepository + - Tests\Fixtures\Repositories\AccessTokenRepository + - Tests\Fixtures\Repositories\ScopeRepository + - Tests\Fixtures\Repositories\AuthCodeRepository + - Tests\Fixtures\Repositories\RefreshTokenRepository + - Tests\Fixtures\Repositories\UserRepository + ', 'neon')); + }, [getmypid(), 5]); + + /** @var Container $container */ + $container = new $class(); + + Assert::count(1, $container->findByType(AuthorizationServer::class)); + Assert::count(1, $container->findByType(AuthCodeGrant::class)); +}); + +// Test implicit grant with accessTokenTTL +Toolkit::test(function (): void { + $loader = new ContainerLoader(Environment::getTmpDir(), true); + $class = $loader->load(function (Compiler $compiler): void { + $compiler->addExtension('oauth2.server', new OAuth2ServerExtension()); + $compiler->loadConfig(FileMock::create(' + oauth2.server: + encryptionKey: "fake" + privateKey: + path: "../../Fixtures/keys/private.key" + passPhrase: "foo" + permissionCheck: false + publicKey: + path: "../../Fixtures/keys/public.key" + permissionCheck: false + grants: + implicit: + ttl: PT2H + accessTokenTTL: PT15M + + services: + - Tests\Fixtures\Repositories\ClientRepository + - Tests\Fixtures\Repositories\AccessTokenRepository + - Tests\Fixtures\Repositories\ScopeRepository + - Tests\Fixtures\Repositories\AuthCodeRepository + - Tests\Fixtures\Repositories\RefreshTokenRepository + - Tests\Fixtures\Repositories\UserRepository + ', 'neon')); + }, [getmypid(), 6]); + + /** @var Container $container */ + $container = new $class(); + + Assert::count(1, $container->findByType(AuthorizationServer::class)); + Assert::count(1, $container->findByType(ImplicitGrant::class)); +}); diff --git a/tests/Cases/E2E/AuthorizationServerTest.php b/tests/Cases/E2E/AuthorizationServerTest.php index 863923f..b775386 100644 --- a/tests/Cases/E2E/AuthorizationServerTest.php +++ b/tests/Cases/E2E/AuthorizationServerTest.php @@ -33,14 +33,14 @@ oauth2.server: encryptionKey: "Fc+FESy6/yfOlXMBW65BXoSZsfWJkP5jCV9w0fyFfw4=" privateKey: - path: "%s/../../fixtures/keys/private.key" + path: "%s/../../Fixtures/keys/private.key" passPhrase: foo permissionCheck: false publicKey: - path: "%s/../../fixtures/keys/public.key" + path: "%s/../../Fixtures/keys/public.key" permissionCheck: false grants: - refreshToken: true + refreshToken: [] services: - Tests\Fixtures\Repositories\ClientRepository diff --git a/tests/fixtures/Repositories/AccessTokenRepository.php b/tests/Fixtures/Repositories/AccessTokenRepository.php similarity index 100% rename from tests/fixtures/Repositories/AccessTokenRepository.php rename to tests/Fixtures/Repositories/AccessTokenRepository.php diff --git a/tests/fixtures/Repositories/AuthCodeRepository.php b/tests/Fixtures/Repositories/AuthCodeRepository.php similarity index 100% rename from tests/fixtures/Repositories/AuthCodeRepository.php rename to tests/Fixtures/Repositories/AuthCodeRepository.php diff --git a/tests/fixtures/Repositories/ClientRepository.php b/tests/Fixtures/Repositories/ClientRepository.php similarity index 100% rename from tests/fixtures/Repositories/ClientRepository.php rename to tests/Fixtures/Repositories/ClientRepository.php diff --git a/tests/fixtures/Repositories/RefreshTokenRepository.php b/tests/Fixtures/Repositories/RefreshTokenRepository.php similarity index 100% rename from tests/fixtures/Repositories/RefreshTokenRepository.php rename to tests/Fixtures/Repositories/RefreshTokenRepository.php diff --git a/tests/fixtures/Repositories/ScopeRepository.php b/tests/Fixtures/Repositories/ScopeRepository.php similarity index 100% rename from tests/fixtures/Repositories/ScopeRepository.php rename to tests/Fixtures/Repositories/ScopeRepository.php diff --git a/tests/fixtures/Repositories/UserRepository.php b/tests/Fixtures/Repositories/UserRepository.php similarity index 100% rename from tests/fixtures/Repositories/UserRepository.php rename to tests/Fixtures/Repositories/UserRepository.php diff --git a/tests/fixtures/keys/private.key b/tests/Fixtures/keys/private.key similarity index 100% rename from tests/fixtures/keys/private.key rename to tests/Fixtures/keys/private.key diff --git a/tests/fixtures/keys/public.key b/tests/Fixtures/keys/public.key similarity index 100% rename from tests/fixtures/keys/public.key rename to tests/Fixtures/keys/public.key diff --git a/tests/bootstrap.php b/tests/bootstrap.php index fddf257..86c92a0 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -7,5 +7,4 @@ exit(1); } -// Configure environment Environment::setup(__DIR__);