diff --git a/.gitattributes b/.gitattributes index 08a4644cd..b84dedf2d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,7 +7,6 @@ /.gitignore export-ignore /.scrutinizer.yml export-ignore /.styleci.yml export-ignore -/.travis.yml export-ignore /phpstan.neon export-ignore /phpunit.xml.dist export-ignore /CHANGELOG.md export-ignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..e54b58bfe --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: composer + directory: "/" + schedule: + interval: daily + time: "11:00" + open-pull-requests-limit: 10 + ignore: + - dependency-name: league/event + versions: + - 3.0.0 diff --git a/.github/workflows/backwards-compatibility.yml b/.github/workflows/backwards-compatibility.yml new file mode 100644 index 000000000..e15e132b5 --- /dev/null +++ b/.github/workflows/backwards-compatibility.yml @@ -0,0 +1,21 @@ +name: "Backwards compatibility check" + +on: + pull_request: + +jobs: + bc-check: + name: "Backwards compatibility check" + + runs-on: "ubuntu-latest" + + steps: + - name: "Checkout" + uses: "actions/checkout@v2" + with: + fetch-depth: 0 + + - name: "Backwards Compatibility Check" + uses: docker://nyholm/roave-bc-check-ga + with: + args: --from=${{ github.event.pull_request.base.sha }} \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..1f1db9e1b --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,42 @@ +name: tests + +on: + push: + pull_request: + schedule: + - cron: '0 0 * * *' + +jobs: + tests: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: [7.2, 7.3, 7.4, 8.0] + stability: [prefer-lowest, prefer-stable] + + name: PHP ${{ matrix.php }} - ${{ matrix.stability }} + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip + coverage: pcov + + - name: Install dependencies + run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-progress + + - name: Execute tests + run: vendor/bin/phpunit --verbose --coverage-clover=coverage.clover + + - name: Code coverage + if: ${{ github.ref == 'refs/heads/master' && matrix.php != 8.0 && github.repository == 'thephpleague/oauth2-server' }} + run: | + wget https://scrutinizer-ci.com/ocular.phar + php ocular.phar code-coverage:upload --format=php-clover coverage.clover diff --git a/.gitignore b/.gitignore index e22101f24..a859c02f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /vendor /composer.lock phpunit.xml +.phpunit.result.cache .idea /examples/vendor examples/public.key diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 35b437cfa..000000000 --- a/.travis.yml +++ /dev/null @@ -1,33 +0,0 @@ -language: php - -dist: bionic -sudo: false - -cache: - directories: - - vendor - -env: - - DEPENDENCIES="" - - DEPENDENCIES="--prefer-lowest --prefer-stable" - -php: - - 7.2 - - 7.3 - - 7.4 - -install: - - composer update --no-interaction --prefer-dist $DEPENDENCIES - -script: - - vendor/bin/phpunit --coverage-clover=coverage.clover - - vendor/bin/phpstan analyse -l 7 -c phpstan.neon src tests - -after_script: - - wget https://scrutinizer-ci.com/ocular.phar - - php ocular.phar code-coverage:upload --format=php-clover coverage.clover - -branches: - only: - - master - - 9.0.0-WIP diff --git a/CHANGELOG.md b/CHANGELOG.md index 5168f9214..b70608580 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,27 +5,76 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## [Unreleased] - -### Added (v9) +### Added - A CryptKeyInterface to allow developers to change the CryptKey implementation with greater ease (PR #1044) - The authorization server can now finalize scopes when a client uses a refresh token (PR #1094) - An AuthorizationRequestInterface to make it easier to extend the AuthorizationRequest (PR #1110) - Ability to set custom claims on a JWT (PR #1122) +### Fixed +- If a refresh token has expired, been revoked, cannot be decrypted, or does not belong to the correct client, the server will now issue an `invalid_grant` error and a HTTP 400 response. In previous versions the server incorrectly issued an `invalid_request` and HTTP 401 response (PR #1042) (PR #1082) + +### Changed +- Authorization Request objects are now created through the factory method, `createAuthorizationRequest()` (PR #1111) +- Changed parameters for `finalizeScopes()` to allow a reference to an auth code ID (PR #1112) + +## [8.3.3] - released 2021-10-11 +### Security +- Removed the use of `LocalFileReference()` in lcobucci/jwt. Function deprecated as per [GHSA-7322-jrq4-x5hf](https://github.com/lcobucci/jwt/security/advisories/GHSA-7322-jrq4-x5hf) (PR #1249) + +## [8.3.2] - released 2021-07-27 +### Changed +- Conditionally support the `StrictValidAt()` method in lcobucci/jwt so we can use version 4.1.x or greater of the library (PR #1236) +- When providing invalid credentials, the library now responds with the error message _The user credentials were incorrect_ (PR #1230) +- Keys are always stored in memory now and are not written to a file in the /tmp directory (PR #1180) +- The regex for matching the bearer token has been simplified (PR #1238) + +## [8.3.1] - released 2021-06-04 +### Fixed +- Revert check on clientID. We will no longer require this to be a string (PR #1233) + +## [8.3.0] - released 2021-06-03 +### Added +- The server will now validate redirect uris according to rfc8252 (PR #1203) +- Events emitted now include the refresh token and access token payloads (PR #1211) +- Use the `revokeRefreshTokens()` function to decide whether refresh tokens are revoked or not upon use (PR #1189) + +### Changed +- Keys are now validated using `openssl_pkey_get_private()` and `openssl_pkey_get_public()` instead of regex matching (PR #1215) + +### Fixed +- The server will now only recognise and handle an authorization header if the value of the header is non-empty. This is to circumvent issues where some common frameworks set this header even if no value is present (PR #1170) +- Added type validation for redirect uri, client ID, client secret, scopes, auth code, state, username, and password inputs (PR #1210) +- Allow scope "0" to be used. Previously this was removed from a request because it failed an `empty()` check (PR #1181) + +## [8.2.4] - released 2020-12-10 +### Fixed +- Reverted the enforcement of at least one redirect_uri for a client. This change has instead been moved to version 9 (PR #1169) + +## [8.2.3] - released 2020-12-02 +### Added +- Re-added support for PHP 7.2 (PR #1165, #1167) + +## [8.2.2] - released 2020-11-30 +### Fixed +- Fix issue where the private key passphrase isn't correctly passed to JWT library (PR #1164) + +## [8.2.1] - released 2020-11-26 +### Fixed +- If you have a password on your private key, it is now passed correctly to the JWT configuration object. (PR #1159) + +## [8.2.0] - released 2020-11-25 ### Added - Add a `getRedirectUri` function to the `OAuthServerException` class (PR #1123) +- Support for PHP 8.0 (PR #1146) -### Fixed (v9) -- If a refresh token has expired, been revoked, cannot be decrypted, or does not belong to the correct client, the server will now issue an `invalid_grant` error and a HTTP 400 response. In previous versions the server incorrectly issued an `invalid_request` and HTTP 401 response (PR #1042) (PR #1082) +### Removed +- Removed support for PHP 7.2 (PR #1146) ### Fixed - Fix typo in parameter hint. `code_challenged` changed to `code_challenge`. Thrown by Auth Code Grant when the code challenge does not match the regex. (PR #1130) - Undefined offset was returned when no client redirect URI was set. Now throw an invalidClient exception if no redirect URI is set against a client (PR #1140) -### Changed (v9) -- Authorization Request objects are now created through the factory method, `createAuthorizationRequest()` (PR #1111) -- Changed parameters for `finalizeScopes()` to allow a reference to an auth code ID (PR #1112) - ## [8.1.1] - released 2020-07-01 ### Fixed @@ -518,7 +567,16 @@ Version 5 is a complete code rewrite. - First major release -[Unreleased]: https://github.com/thephpleague/oauth2-server/compare/8.1.1...HEAD +[Unreleased]: https://github.com/thephpleague/oauth2-server/compare/8.3.3...HEAD +[8.3.3]: https://github.com/thephpleague/oauth2-server/compare/8.3.2...8.3.3 +[8.3.2]: https://github.com/thephpleague/oauth2-server/compare/8.3.1...8.3.2 +[8.3.1]: https://github.com/thephpleague/oauth2-server/compare/8.3.0...8.3.1 +[8.3.0]: https://github.com/thephpleague/oauth2-server/compare/8.2.4...8.3.0 +[8.2.4]: https://github.com/thephpleague/oauth2-server/compare/8.2.3...8.2.4 +[8.2.3]: https://github.com/thephpleague/oauth2-server/compare/8.2.2...8.2.3 +[8.2.2]: https://github.com/thephpleague/oauth2-server/compare/8.2.1...8.2.2 +[8.2.1]: https://github.com/thephpleague/oauth2-server/compare/8.2.0...8.2.1 +[8.2.0]: https://github.com/thephpleague/oauth2-server/compare/8.1.1...8.2.0 [8.1.1]: https://github.com/thephpleague/oauth2-server/compare/8.1.0...8.1.1 [8.1.0]: https://github.com/thephpleague/oauth2-server/compare/8.0.0...8.1.0 [8.0.0]: https://github.com/thephpleague/oauth2-server/compare/7.4.0...8.0.0 diff --git a/README.md b/README.md index b51e6f8f3..5307b840a 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,10 @@ [![Latest Version](http://img.shields.io/packagist/v/league/oauth2-server.svg?style=flat-square)](https://github.com/thephpleague/oauth2-server/releases) [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) -[![Build Status](https://img.shields.io/travis/thephpleague/oauth2-server/master.svg?style=flat-square)](https://travis-ci.org/thephpleague/oauth2-server) +[![Build Status](https://github.com/thephpleague/oauth2-server/workflows/tests/badge.svg)](https://github.com/thephpleague/oauth2-server/actions) [![Coverage Status](https://img.shields.io/scrutinizer/coverage/g/thephpleague/oauth2-server.svg?style=flat-square)](https://scrutinizer-ci.com/g/thephpleague/oauth2-server/code-structure) [![Quality Score](https://img.shields.io/scrutinizer/g/thephpleague/oauth2-server.svg?style=flat-square)](https://scrutinizer-ci.com/g/thephpleague/oauth2-server) [![Total Downloads](https://img.shields.io/packagist/dt/league/oauth2-server.svg?style=flat-square)](https://packagist.org/packages/league/oauth2-server) -[![PHPStan](https://img.shields.io/badge/PHPStan-enabled-brightgreen.svg?style=flat-square)](https://github.com/phpstan/phpstan) `league/oauth2-server` is a standards compliant implementation of an [OAuth 2.0](https://tools.ietf.org/html/rfc6749) authorization server written in PHP which makes working with OAuth 2.0 trivial. You can easily configure an OAuth 2.0 server to protect your API with access tokens, or allow clients to request new access tokens and refresh them. @@ -29,11 +28,12 @@ This library was created by Alex Bilbie. Find him on Twitter at [@alexbilbie](ht ## Requirements -The following versions of PHP are supported: +The latest version of this package supports the following versions of PHP: * PHP 7.2 * PHP 7.3 * PHP 7.4 +* PHP 8.0 The `openssl` and `json` extensions are also required. @@ -52,16 +52,15 @@ You can contribute to the documentation in the [gh-pages branch](https://github. ## Testing -The library uses [PHPUnit](https://phpunit.de/) for unit tests and [PHPStan](https://github.com/phpstan/phpstan) for static analysis of the code. +The library uses [PHPUnit](https://phpunit.de/) for unit tests. ``` vendor/bin/phpunit -vendor/bin/phpstan analyse -l 7 -c phpstan.neon src tests ``` -## Continous Integration +## Continuous Integration -We use [Travis CI](https://travis-ci.org/), [Scrutinizer](https://scrutinizer-ci.com/), and [StyleCI](https://styleci.io/) for continuous integration. Check out [our](https://github.com/thephpleague/oauth2-server/blob/master/.travis.yml) [configuration](https://github.com/thephpleague/oauth2-server/blob/master/.scrutinizer.yml) [files](https://github.com/thephpleague/oauth2-server/blob/master/.styleci.yml) if you'd like to know more. +We use [Github Actions](https://github.com/features/actions), [Scrutinizer](https://scrutinizer-ci.com/), and [StyleCI](https://styleci.io/) for continuous integration. Check out [our](https://github.com/thephpleague/oauth2-server/blob/master/.github/workflows/tests.yml) [configuration](https://github.com/thephpleague/oauth2-server/blob/master/.scrutinizer.yml) [files](https://github.com/thephpleague/oauth2-server/blob/master/.styleci.yml) if you'd like to know more. ## Community Integrations @@ -69,7 +68,7 @@ We use [Travis CI](https://travis-ci.org/), [Scrutinizer](https://scrutinizer-ci * [Laravel Passport](https://github.com/laravel/passport) * [OAuth 2 Server for CakePHP 3](https://github.com/uafrica/oauth-server) * [OAuth 2 Server for Mezzio](https://github.com/mezzio/mezzio-authentication-oauth2) -* [Trikoder OAuth 2 Bundle (Symfony)](https://github.com/trikoder/oauth2-bundle) +* [OAuth 2 Server Bundle (Symfony)](https://github.com/thephpleague/oauth2-server-bundle) * [Heimdall for CodeIgniter 4](https://github.com/ezralazuardy/heimdall) ## Changelog diff --git a/composer.json b/composer.json index 658cc6553..7cfbc0cc4 100644 --- a/composer.json +++ b/composer.json @@ -4,19 +4,19 @@ "homepage": "https://oauth2.thephpleague.com/", "license": "MIT", "require": { - "php": ">=7.2.0", + "php": "^7.2 || ^8.0", "ext-openssl": "*", "league/event": "^2.2", - "lcobucci/jwt": "^3.3.1", + "lcobucci/jwt": "^3.4.6 || ^4.0.4", "psr/http-message": "^1.0.1", "defuse/php-encryption": "^2.2.1", "ext-json": "*" }, "require-dev": { - "phpunit/phpunit": "^8.5.4 || ^9.1.3", - "laminas/laminas-diactoros": "^2.3.0", - "phpstan/phpstan": "^0.11.19", - "phpstan/phpstan-phpunit": "^0.11.2", + "phpunit/phpunit": "^8.5.13", + "laminas/laminas-diactoros": "^2.4.1", + "phpstan/phpstan": "^0.12.57", + "phpstan/phpstan-phpunit": "^0.12.16", "roave/security-advisories": "dev-master" }, "repositories": [ diff --git a/examples/composer.json b/examples/composer.json index e1c44efc8..087caab72 100644 --- a/examples/composer.json +++ b/examples/composer.json @@ -1,13 +1,13 @@ { "require": { - "slim/slim": "^3.0.0" + "slim/slim": "^3.12.3" }, "require-dev": { "league/event": "^2.2", - "lcobucci/jwt": "^3.3", - "psr/http-message": "^1.0", - "defuse/php-encryption": "^2.2", - "laminas/laminas-diactoros": "^2.1.2" + "lcobucci/jwt": "^3.4.6 || ^4.0.4", + "psr/http-message": "^1.0.1", + "defuse/php-encryption": "^2.2.1", + "laminas/laminas-diactoros": "^2.5.0" }, "autoload": { "psr-4": { diff --git a/examples/composer.lock b/examples/composer.lock index 6de13b69f..f7affe0e9 100644 --- a/examples/composer.lock +++ b/examples/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5bcbefe6cdff10a268399b1d138647ea", + "content-hash": "1f38bc4bb33ddc5527b3097d1118b227", "packages": [ { "name": "nikic/fast-route", @@ -54,29 +54,29 @@ }, { "name": "pimple/pimple", - "version": "v3.2.3", + "version": "v3.3.1", "source": { "type": "git", "url": "https://github.com/silexphp/Pimple.git", - "reference": "9e403941ef9d65d20cba7d54e29fe906db42cf32" + "reference": "21e45061c3429b1e06233475cc0e1f6fc774d5b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/silexphp/Pimple/zipball/9e403941ef9d65d20cba7d54e29fe906db42cf32", - "reference": "9e403941ef9d65d20cba7d54e29fe906db42cf32", + "url": "https://api.github.com/repos/silexphp/Pimple/zipball/21e45061c3429b1e06233475cc0e1f6fc774d5b0", + "reference": "21e45061c3429b1e06233475cc0e1f6fc774d5b0", "shasum": "" }, "require": { - "php": ">=5.3.0", + "php": ">=7.2.5", "psr/container": "^1.0" }, "require-dev": { - "symfony/phpunit-bridge": "^3.2" + "symfony/phpunit-bridge": "^5.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.2.x-dev" + "dev-master": "3.3.x-dev" } }, "autoload": { @@ -95,12 +95,12 @@ } ], "description": "Pimple, a simple Dependency Injection Container", - "homepage": "http://pimple.sensiolabs.org", + "homepage": "https://pimple.symfony.com", "keywords": [ "container", "dependency injection" ], - "time": "2018-01-21T07:42:36+00:00" + "time": "2020-11-24T20:35:42+00:00" }, { "name": "psr/container", @@ -341,46 +341,50 @@ }, { "name": "laminas/laminas-diactoros", - "version": "2.2.1", + "version": "2.5.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-diactoros.git", - "reference": "064f0b20e832bb232d0311f915c7422fef1b1857" + "reference": "4ff7400c1c12e404144992ef43c8b733fd9ad516" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-diactoros/zipball/064f0b20e832bb232d0311f915c7422fef1b1857", - "reference": "064f0b20e832bb232d0311f915c7422fef1b1857", + "url": "https://api.github.com/repos/laminas/laminas-diactoros/zipball/4ff7400c1c12e404144992ef43c8b733fd9ad516", + "reference": "4ff7400c1c12e404144992ef43c8b733fd9ad516", "shasum": "" }, "require": { "laminas/laminas-zendframework-bridge": "^1.0", - "php": "^7.1", + "php": "^7.3 || ~8.0.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.0" }, + "conflict": { + "phpspec/prophecy": "<1.9.0" + }, "provide": { "psr/http-factory-implementation": "1.0", "psr/http-message-implementation": "1.0" }, "replace": { - "zendframework/zend-diactoros": "self.version" + "zendframework/zend-diactoros": "^2.2.1" }, "require-dev": { "ext-curl": "*", "ext-dom": "*", + "ext-gd": "*", "ext-libxml": "*", - "http-interop/http-factory-tests": "^0.5.0", + "http-interop/http-factory-tests": "^0.8.0", "laminas/laminas-coding-standard": "~1.0.0", - "php-http/psr7-integration-tests": "dev-master", - "phpunit/phpunit": "^7.0.2" + "php-http/psr7-integration-tests": "^1.1", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.1" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "2.1.x-dev", - "dev-develop": "2.2.x-dev", - "dev-release-1.8": "1.8.x-dev" + "laminas": { + "config-provider": "Laminas\\Diactoros\\ConfigProvider", + "module": "Laminas\\Diactoros" } }, "autoload": { @@ -416,37 +420,34 @@ "http", "laminas", "psr", + "psr-17", "psr-7" ], - "time": "2019-12-31T16:41:56+00:00" + "time": "2020-11-18T18:39:28+00:00" }, { "name": "laminas/laminas-zendframework-bridge", - "version": "1.0.0", + "version": "1.1.1", "source": { "type": "git", "url": "https://github.com/laminas/laminas-zendframework-bridge.git", - "reference": "32d7095e436a31b8d98e485a5c63d70df74915a8" + "reference": "6ede70583e101030bcace4dcddd648f760ddf642" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-zendframework-bridge/zipball/32d7095e436a31b8d98e485a5c63d70df74915a8", - "reference": "32d7095e436a31b8d98e485a5c63d70df74915a8", + "url": "https://api.github.com/repos/laminas/laminas-zendframework-bridge/zipball/6ede70583e101030bcace4dcddd648f760ddf642", + "reference": "6ede70583e101030bcace4dcddd648f760ddf642", "shasum": "" }, "require": { - "php": "^5.6 || ^7.0" + "php": "^5.6 || ^7.0 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^5.7 || ^6.5 || ^7.5 || ^8.1", + "phpunit/phpunit": "^5.7 || ^6.5 || ^7.5 || ^8.1 || ^9.3", "squizlabs/php_codesniffer": "^3.5" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev", - "dev-develop": "1.1.x-dev" - }, "laminas": { "module": "Laminas\\ZendFrameworkBridge" } @@ -470,38 +471,92 @@ "laminas", "zf" ], - "time": "2019-12-31T15:24:03+00:00" + "time": "2020-09-14T14:23:00+00:00" + }, + { + "name": "lcobucci/clock", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/clock.git", + "reference": "353d83fe2e6ae95745b16b3d911813df6a05bfb3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/clock/zipball/353d83fe2e6ae95745b16b3d911813df6a05bfb3", + "reference": "353d83fe2e6ae95745b16b3d911813df6a05bfb3", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "infection/infection": "^0.17", + "lcobucci/coding-standard": "^6.0", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^0.12", + "phpstan/phpstan-deprecation-rules": "^0.12", + "phpstan/phpstan-phpunit": "^0.12", + "phpstan/phpstan-strict-rules": "^0.12", + "phpunit/php-code-coverage": "9.1.4", + "phpunit/phpunit": "9.3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\Clock\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com" + } + ], + "description": "Yet another clock abstraction", + "time": "2020-08-27T18:56:02+00:00" }, { "name": "lcobucci/jwt", - "version": "3.3.1", + "version": "4.0.0", "source": { "type": "git", "url": "https://github.com/lcobucci/jwt.git", - "reference": "a11ec5f4b4d75d1fcd04e133dede4c317aac9e18" + "reference": "6d8665ccd924dc076a9b65d1ea8abe21d68f6958" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/lcobucci/jwt/zipball/a11ec5f4b4d75d1fcd04e133dede4c317aac9e18", - "reference": "a11ec5f4b4d75d1fcd04e133dede4c317aac9e18", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/6d8665ccd924dc076a9b65d1ea8abe21d68f6958", + "reference": "6d8665ccd924dc076a9b65d1ea8abe21d68f6958", "shasum": "" }, "require": { "ext-mbstring": "*", "ext-openssl": "*", - "php": "^5.6 || ^7.0" + "lcobucci/clock": "^2.0", + "php": "^7.4 || ^8.0" }, "require-dev": { - "mikey179/vfsstream": "~1.5", - "phpmd/phpmd": "~2.2", - "phpunit/php-invoker": "~1.1", - "phpunit/phpunit": "^5.7 || ^7.3", - "squizlabs/php_codesniffer": "~2.3" + "infection/infection": "^0.20", + "lcobucci/coding-standard": "^6.0", + "mikey179/vfsstream": "^1.6", + "phpbench/phpbench": "^0.17", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^0.12", + "phpstan/phpstan-deprecation-rules": "^0.12", + "phpstan/phpstan-phpunit": "^0.12", + "phpstan/phpstan-strict-rules": "^0.12", + "phpunit/php-invoker": "^3.1", + "phpunit/phpunit": "^9.4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.1-dev" + "dev-master": "4.0-dev" } }, "autoload": { @@ -515,7 +570,7 @@ ], "authors": [ { - "name": "Luís Otávio Cobucci Oblonczyk", + "name": "Luís Cobucci", "email": "lcobucci@gmail.com", "role": "Developer" } @@ -525,7 +580,7 @@ "JWS", "jwt" ], - "time": "2019-05-24T18:30:49+00:00" + "time": "2020-11-25T02:06:12+00:00" }, { "name": "league/event", @@ -579,20 +634,20 @@ }, { "name": "paragonie/random_compat", - "version": "v9.99.99", + "version": "v9.99.100", "source": { "type": "git", "url": "https://github.com/paragonie/random_compat.git", - "reference": "84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95" + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/random_compat/zipball/84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95", - "reference": "84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", "shasum": "" }, "require": { - "php": "^7" + "php": ">= 7" }, "require-dev": { "phpunit/phpunit": "4.*|5.*", @@ -620,7 +675,7 @@ "pseudorandom", "random" ], - "time": "2018-07-02T15:55:56+00:00" + "time": "2020-10-15T08:29:30+00:00" }, { "name": "psr/http-factory", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 92564086b..5f8851b28 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -9,16 +9,6 @@ src - - src/ResponseTypes/DefaultTemplates - src/TemplateRenderer - - - - - diff --git a/src/AuthorizationServer.php b/src/AuthorizationServer.php index 654a08388..3ce2369fd 100644 --- a/src/AuthorizationServer.php +++ b/src/AuthorizationServer.php @@ -85,6 +85,11 @@ class AuthorizationServer implements EmitterAwareInterface */ private $defaultScope = ''; + /** + * @var bool + */ + private $revokeRefreshTokens = true; + /** * New server instance. * @@ -146,6 +151,7 @@ public function enableGrantType(GrantTypeInterface $grantType, DateInterval $acc $grantType->setPrivateKey($this->privateKey); $grantType->setEmitter($this->getEmitter()); $grantType->setEncryptionKey($this->encryptionKey); + $grantType->revokeRefreshTokens($this->revokeRefreshTokens); $this->enabledGrantTypes[$grantType->getIdentifier()] = $grantType; $this->grantTypeAccessTokenTTL[$grantType->getIdentifier()] = $accessTokenTTL; @@ -245,4 +251,14 @@ public function setDefaultScope($defaultScope) { $this->defaultScope = $defaultScope; } + + /** + * Sets whether to revoke refresh tokens or not (for all grant types). + * + * @param bool $revokeRefreshTokens + */ + public function revokeRefreshTokens(bool $revokeRefreshTokens): void + { + $this->revokeRefreshTokens = $revokeRefreshTokens; + } } diff --git a/src/AuthorizationValidators/BearerTokenValidator.php b/src/AuthorizationValidators/BearerTokenValidator.php index 69efea75a..7687843c5 100644 --- a/src/AuthorizationValidators/BearerTokenValidator.php +++ b/src/AuthorizationValidators/BearerTokenValidator.php @@ -9,17 +9,20 @@ namespace League\OAuth2\Server\AuthorizationValidators; -use BadMethodCallException; -use InvalidArgumentException; -use Lcobucci\JWT\Parser; +use DateTimeZone; +use Lcobucci\Clock\SystemClock; +use Lcobucci\JWT\Configuration; +use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Rsa\Sha256; -use Lcobucci\JWT\ValidationData; +use Lcobucci\JWT\Validation\Constraint\SignedWith; +use Lcobucci\JWT\Validation\Constraint\StrictValidAt; +use Lcobucci\JWT\Validation\Constraint\ValidAt; +use Lcobucci\JWT\Validation\RequiredConstraintsViolated; use League\OAuth2\Server\CryptKeyInterface; use League\OAuth2\Server\CryptTrait; use League\OAuth2\Server\Exception\OAuthServerException; use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface; use Psr\Http\Message\ServerRequestInterface; -use RuntimeException; class BearerTokenValidator implements AuthorizationValidatorInterface { @@ -35,6 +38,11 @@ class BearerTokenValidator implements AuthorizationValidatorInterface */ protected $publicKey; + /** + * @var Configuration + */ + private $jwtConfiguration; + /** * @param AccessTokenRepositoryInterface $accessTokenRepository */ @@ -51,6 +59,29 @@ public function __construct(AccessTokenRepositoryInterface $accessTokenRepositor public function setPublicKey(CryptKeyInterface $key) { $this->publicKey = $key; + + $this->initJwtConfiguration(); + } + + /** + * Initialise the JWT configuration. + */ + private function initJwtConfiguration() + { + $this->jwtConfiguration = Configuration::forSymmetricSigner( + new Sha256(), + InMemory::plainText('') + ); + + $this->jwtConfiguration->setValidationConstraints( + \class_exists(StrictValidAt::class) + ? new StrictValidAt(new SystemClock(new DateTimeZone(\date_default_timezone_get()))) + : new ValidAt(new SystemClock(new DateTimeZone(\date_default_timezone_get()))), + new SignedWith( + new Sha256(), + InMemory::plainText($this->publicKey->getKeyContents(), $this->publicKey->getPassPhrase() ?? '') + ) + ); } /** @@ -63,44 +94,47 @@ public function validateAuthorization(ServerRequestInterface $request) } $header = $request->getHeader('authorization'); - $jwt = \trim((string) \preg_replace('/^(?:\s+)?Bearer\s/', '', $header[0])); + $jwt = \trim((string) \preg_replace('/^\s*Bearer\s/', '', $header[0])); try { - // Attempt to parse and validate the JWT - $token = (new Parser())->parse($jwt); - try { - if ($token->verify(new Sha256(), $this->publicKey->getKeyPath()) === false) { - throw OAuthServerException::accessDenied('Access token could not be verified'); - } - } catch (BadMethodCallException $exception) { - throw OAuthServerException::accessDenied('Access token is not signed', null, $exception); - } - - // Ensure access token hasn't expired - $data = new ValidationData(); - $data->setCurrentTime(\time()); - - if ($token->validate($data) === false) { - throw OAuthServerException::accessDenied('Access token is invalid'); - } - } catch (InvalidArgumentException $exception) { - // JWT couldn't be parsed so return the request as is + // Attempt to parse the JWT + $token = $this->jwtConfiguration->parser()->parse($jwt); + } catch (\Lcobucci\JWT\Exception $exception) { throw OAuthServerException::accessDenied($exception->getMessage(), null, $exception); - } catch (RuntimeException $exception) { - // JWT couldn't be parsed so return the request as is - throw OAuthServerException::accessDenied('Error while decoding to JSON', 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'); + } + + $claims = $token->claims(); + // Check if token has been revoked - if ($this->accessTokenRepository->isAccessTokenRevoked($token->getClaim('jti'))) { + 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', $token->getClaim('jti')) - ->withAttribute('oauth_client_id', $token->getClaim('aud')) - ->withAttribute('oauth_user_id', $token->getClaim('sub')) - ->withAttribute('oauth_scopes', $token->getClaim('scopes')); + ->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/src/CryptKey.php b/src/CryptKey.php index bd7800e20..45604f16d 100644 --- a/src/CryptKey.php +++ b/src/CryptKey.php @@ -12,13 +12,20 @@ namespace League\OAuth2\Server; use LogicException; -use RuntimeException; class CryptKey implements CryptKeyInterface { + /** @deprecated left for backward compatibility check */ const RSA_KEY_PATTERN = '/^(-----BEGIN (RSA )?(PUBLIC|PRIVATE) KEY-----)\R.*(-----END (RSA )?(PUBLIC|PRIVATE) KEY-----)\R?$/s'; + private const FILE_PREFIX = 'file://'; + + /** + * @var string Key contents + */ + protected $keyContents; + /** * @var string */ @@ -36,67 +43,77 @@ class CryptKey implements CryptKeyInterface */ public function __construct($keyPath, $passPhrase = null, $keyPermissionsCheck = true) { - if ($rsaMatch = \preg_match(static::RSA_KEY_PATTERN, $keyPath)) { - $keyPath = $this->saveKeyToFile($keyPath); - } elseif ($rsaMatch === false) { - throw new \RuntimeException( - \sprintf('PCRE error [%d] encountered during key match attempt', \preg_last_error()) - ); - } + $this->passPhrase = $passPhrase; - if (\strpos($keyPath, 'file://') !== 0) { - $keyPath = 'file://' . $keyPath; - } + if (\strpos($keyPath, self::FILE_PREFIX) !== 0 && $this->isValidKey($keyPath, $this->passPhrase ?? '')) { + $this->keyContents = $keyPath; + $this->keyPath = ''; + // There's no file, so no need for permission check. + $keyPermissionsCheck = false; + } elseif (\is_file($keyPath)) { + if (\strpos($keyPath, self::FILE_PREFIX) !== 0) { + $keyPath = self::FILE_PREFIX . $keyPath; + } - if (!\file_exists($keyPath) || !\is_readable($keyPath)) { - throw new LogicException(\sprintf('Key path "%s" does not exist or is not readable', $keyPath)); + if (!\is_readable($keyPath)) { + throw new LogicException(\sprintf('Key path "%s" does not exist or is not readable', $keyPath)); + } + $this->keyContents = \file_get_contents($keyPath); + $this->keyPath = $keyPath; + if (!$this->isValidKey($this->keyContents, $this->passPhrase ?? '')) { + throw new LogicException('Unable to read key from file ' . $keyPath); + } + } else { + throw new LogicException('Unable to read key from file ' . $keyPath); } if ($keyPermissionsCheck === true) { // Verify the permissions of the key - $keyPathPerms = \decoct(\fileperms($keyPath) & 0777); + $keyPathPerms = \decoct(\fileperms($this->keyPath) & 0777); if (\in_array($keyPathPerms, ['400', '440', '600', '640', '660'], true) === false) { - \trigger_error(\sprintf( - 'Key file "%s" permissions are not correct, recommend changing to 600 or 660 instead of %s', - $keyPath, - $keyPathPerms - ), E_USER_NOTICE); + \trigger_error( + \sprintf( + 'Key file "%s" permissions are not correct, recommend changing to 600 or 660 instead of %s', + $this->keyPath, + $keyPathPerms + ), + E_USER_NOTICE + ); } } + } - $this->keyPath = $keyPath; - $this->passPhrase = $passPhrase; + /** + * Get key contents + * + * @return string Key contents + */ + public function getKeyContents(): string + { + return $this->keyContents; } /** - * @param string $key + * Validate key contents. * - * @throws RuntimeException + * @param string $contents + * @param string $passPhrase * - * @return string + * @return bool */ - private function saveKeyToFile($key) + private function isValidKey($contents, $passPhrase) { - $tmpDir = \sys_get_temp_dir(); - $keyPath = $tmpDir . '/' . \sha1($key) . '.key'; - - if (\file_exists($keyPath)) { - return 'file://' . $keyPath; - } - - if (\file_put_contents($keyPath, $key) === false) { - // @codeCoverageIgnoreStart - throw new RuntimeException(\sprintf('Unable to write key file to temporary directory "%s"', $tmpDir)); - // @codeCoverageIgnoreEnd - } - - if (\chmod($keyPath, 0600) === false) { - // @codeCoverageIgnoreStart - throw new RuntimeException(\sprintf('The key file "%s" file mode could not be changed with chmod to 600', $keyPath)); - // @codeCoverageIgnoreEnd + $pkey = \openssl_pkey_get_private($contents, $passPhrase) ?: \openssl_pkey_get_public($contents); + if ($pkey === false) { + return false; } + $details = \openssl_pkey_get_details($pkey); - return 'file://' . $keyPath; + return $details !== false && \in_array( + $details['type'] ?? -1, + [OPENSSL_KEYTYPE_RSA, OPENSSL_KEYTYPE_EC], + true + ); } /** diff --git a/src/Entities/Traits/AccessTokenTrait.php b/src/Entities/Traits/AccessTokenTrait.php index 4e6338bc3..ca97e3c5f 100644 --- a/src/Entities/Traits/AccessTokenTrait.php +++ b/src/Entities/Traits/AccessTokenTrait.php @@ -10,8 +10,8 @@ namespace League\OAuth2\Server\Entities\Traits; use DateTimeImmutable; -use Lcobucci\JWT\Builder; -use Lcobucci\JWT\Signer\Key; +use Lcobucci\JWT\Configuration; +use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Rsa\Sha256; use Lcobucci\JWT\Token; use League\OAuth2\Server\CryptKeyInterface; @@ -26,6 +26,11 @@ trait AccessTokenTrait */ private $privateKey; + /** + * @var Configuration + */ + private $jwtConfiguration; + /** * Set the private key used to encrypt this access token. */ @@ -34,31 +39,42 @@ public function setPrivateKey(CryptKeyInterface $privateKey) $this->privateKey = $privateKey; } + /** + * Initialise the JWT Configuration. + */ + public function initJwtConfiguration() + { + $this->jwtConfiguration = Configuration::forAsymmetricSigner( + new Sha256(), + InMemory::plainText($this->privateKey->getKeyContents(), $this->privateKey->getPassPhrase() ?? ''), + InMemory::plainText('') + ); + } + /** * Generate a JWT from the access token * - * @param CryptKeyInterface $privateKey - * * @return Token */ - private function convertToJWT(CryptKeyInterface $privateKey) + private function convertToJWT() { - $builder = new Builder(); + $this->initJwtConfiguration(); + + $builder = $this->jwtConfiguration->builder(); + $builder->permittedFor($this->getClient()->getIdentifier()) ->identifiedBy($this->getIdentifier()) - ->issuedAt(\time()) - ->canOnlyBeUsedAfter(\time()) - ->expiresAt($this->getExpiryDateTime()->getTimestamp()) + ->issuedAt(new DateTimeImmutable()) + ->canOnlyBeUsedAfter(new DateTimeImmutable()) + ->expiresAt($this->getExpiryDateTime()) ->relatedTo((string) $this->getUserIdentifier()); foreach ($this->getClaims() as $claim) { $builder->withClaim($claim->getName(), $claim->getValue()); } - return $builder - // Set scope claim late to prevent it from being overridden. - ->withClaim('scopes', $this->getScopes()) - ->getToken(new Sha256(), new Key($privateKey->getKeyPath(), $privateKey->getPassPhrase())); + return $builder->withClaim('scopes', $this->getScopes()) + ->getToken($this->jwtConfiguration->signer(), $this->jwtConfiguration->signingKey()); } /** @@ -66,7 +82,7 @@ private function convertToJWT(CryptKeyInterface $privateKey) */ public function __toString() { - return (string) $this->convertToJWT($this->privateKey); + return $this->convertToJWT()->toString(); } /** diff --git a/src/Exception/OAuthServerException.php b/src/Exception/OAuthServerException.php index bb6af44de..fc1b9f022 100644 --- a/src/Exception/OAuthServerException.php +++ b/src/Exception/OAuthServerException.php @@ -189,7 +189,7 @@ public static function invalidScope($scope, $redirectUri = null) */ public static function invalidCredentials() { - return new static('The user credentials were incorrect.', 6, 'invalid_credentials', 401); + return new static('The user credentials were incorrect.', 6, 'invalid_grant', 400); } /** @@ -334,13 +334,12 @@ public function getHttpHeaders() // respond with an HTTP 401 (Unauthorized) status code and // include the "WWW-Authenticate" response header field // matching the authentication scheme used by the client. - // @codeCoverageIgnoreStart - if ($this->errorType === 'invalid_client' && $this->serverRequest->hasHeader('Authorization') === true) { + if ($this->errorType === 'invalid_client' && $this->requestHasAuthorizationHeader()) { $authScheme = \strpos($this->serverRequest->getHeader('Authorization')[0], 'Bearer') === 0 ? 'Bearer' : 'Basic'; $headers['WWW-Authenticate'] = $authScheme . ' realm="OAuth"'; } - // @codeCoverageIgnoreEnd + return $headers; } @@ -386,4 +385,32 @@ public function getHint() { return $this->hint; } + + /** + * Check if the request has a non-empty 'Authorization' header value. + * + * Returns true if the header is present and not an empty string, false + * otherwise. + * + * @return bool + */ + private function requestHasAuthorizationHeader() + { + if (!$this->serverRequest->hasHeader('Authorization')) { + return false; + } + + $authorizationHeader = $this->serverRequest->getHeader('Authorization'); + + // Common .htaccess configurations yield an empty string for the + // 'Authorization' header when one is not provided by the client. + // For practical purposes that case should be treated as though the + // header isn't present. + // See https://github.com/thephpleague/oauth2-server/issues/1162 + if (empty($authorizationHeader) || empty($authorizationHeader[0])) { + return false; + } + + return true; + } } diff --git a/src/Grant/AbstractGrant.php b/src/Grant/AbstractGrant.php index c77330e26..530100938 100644 --- a/src/Grant/AbstractGrant.php +++ b/src/Grant/AbstractGrant.php @@ -25,6 +25,7 @@ use League\OAuth2\Server\Entities\ScopeEntityInterface; use League\OAuth2\Server\Exception\OAuthServerException; use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException; +use League\OAuth2\Server\RedirectUriValidators\RedirectUriValidator; use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface; use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface; use League\OAuth2\Server\Repositories\ClaimRepositoryInterface; @@ -100,6 +101,11 @@ abstract class AbstractGrant implements GrantTypeInterface */ protected $defaultScope; + /** + * @var bool + */ + protected $revokeRefreshTokens; + /** * @param ClientRepositoryInterface $clientRepository */ @@ -182,6 +188,14 @@ public function setDefaultScope($scope) $this->defaultScope = $scope; } + /** + * @param bool $revokeRefreshTokens + */ + public function revokeRefreshTokens(bool $revokeRefreshTokens) + { + $this->revokeRefreshTokens = $revokeRefreshTokens; + } + /** * Validate the client. * @@ -193,7 +207,7 @@ public function setDefaultScope($scope) */ protected function validateClient(ServerRequestInterface $request) { - list($clientId, $clientSecret) = $this->getClientCredentials($request); + [$clientId, $clientSecret] = $this->getClientCredentials($request); if ($this->clientRepository->validateClient($clientId, $clientSecret, $this->getIdentifier()) === false) { $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); @@ -207,6 +221,10 @@ protected function validateClient(ServerRequestInterface $request) $redirectUri = $this->getRequestParameter('redirect_uri', $request, null); if ($redirectUri !== null) { + if (!\is_string($redirectUri)) { + throw OAuthServerException::invalidRequest('redirect_uri'); + } + $this->validateRedirectUri($redirectUri, $client, $request); } @@ -232,7 +250,7 @@ protected function getClientEntityOrFail($clientId, ServerRequestInterface $requ { $client = $this->clientRepository->getClientEntity($clientId); - if ($client instanceof ClientEntityInterface === false || empty($client->getRedirectUri())) { + if ($client instanceof ClientEntityInterface === false) { $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); throw OAuthServerException::invalidClient($request); } @@ -250,7 +268,7 @@ protected function getClientEntityOrFail($clientId, ServerRequestInterface $requ */ protected function getClientCredentials(ServerRequestInterface $request) { - list($basicAuthUser, $basicAuthPassword) = $this->getBasicAuthCredentials($request); + [$basicAuthUser, $basicAuthPassword] = $this->getBasicAuthCredentials($request); $clientId = $this->getRequestParameter('client_id', $request, $basicAuthUser); @@ -260,6 +278,10 @@ protected function getClientCredentials(ServerRequestInterface $request) $clientSecret = $this->getRequestParameter('client_secret', $request, $basicAuthPassword); + if ($clientSecret !== null && !\is_string($clientSecret)) { + throw OAuthServerException::invalidRequest('client_secret'); + } + return [$clientId, $clientSecret]; } @@ -278,14 +300,8 @@ protected function validateRedirectUri( ClientEntityInterface $client, ServerRequestInterface $request ) { - if (\is_string($client->getRedirectUri()) - && (\strcmp($client->getRedirectUri(), $redirectUri) !== 0) - ) { - $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); - throw OAuthServerException::invalidClient($request); - } elseif (\is_array($client->getRedirectUri()) - && \in_array($redirectUri, $client->getRedirectUri(), true) === false - ) { + $validator = new RedirectUriValidator($client->getRedirectUri()); + if (!$validator->validateRedirectUri($redirectUri)) { $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); throw OAuthServerException::invalidClient($request); } @@ -303,10 +319,16 @@ protected function validateRedirectUri( */ public function validateScopes($scopes, $redirectUri = null) { - if (!\is_array($scopes)) { + if ($scopes === null) { + $scopes = []; + } elseif (\is_string($scopes)) { $scopes = $this->convertScopesQueryStringToArray($scopes); } + if (!\is_array($scopes)) { + throw OAuthServerException::invalidRequest('scope'); + } + $validScopes = []; foreach ($scopes as $scopeItem) { @@ -329,10 +351,10 @@ public function validateScopes($scopes, $redirectUri = null) * * @return array */ - private function convertScopesQueryStringToArray($scopes) + private function convertScopesQueryStringToArray(string $scopes) { return \array_filter(\explode(self::SCOPE_DELIMITER_STRING, \trim($scopes)), function ($scope) { - return !empty($scope); + return $scope !== ''; }); } diff --git a/src/Grant/AuthCodeGrant.php b/src/Grant/AuthCodeGrant.php index fb57aaa24..2d9f9d738 100644 --- a/src/Grant/AuthCodeGrant.php +++ b/src/Grant/AuthCodeGrant.php @@ -20,8 +20,11 @@ use League\OAuth2\Server\Exception\OAuthServerException; use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface; use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; +use League\OAuth2\Server\RequestAccessTokenEvent; use League\OAuth2\Server\RequestEvent; +use League\OAuth2\Server\RequestRefreshTokenEvent; use League\OAuth2\Server\RequestTypes\AuthorizationRequestInterface; +use League\OAuth2\Server\RequestTypes\AuthorizationRequest; use League\OAuth2\Server\ResponseTypes\RedirectResponse; use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; use LogicException; @@ -106,7 +109,7 @@ public function respondToAccessTokenRequest( $encryptedAuthCode = $this->getRequestParameter('code', $request, null); - if ($encryptedAuthCode === null) { + if (!\is_string($encryptedAuthCode)) { throw OAuthServerException::invalidRequest('code'); } @@ -173,14 +176,14 @@ public function respondToAccessTokenRequest( // Issue and persist new access token $accessToken = $this->issueAccessToken($accessTokenTTL, $client, $authCodePayload->user_id, $scopes, $privateClaims); - $this->getEmitter()->emit(new RequestEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request)); + $this->getEmitter()->emit(new RequestAccessTokenEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request, $accessToken)); $responseType->setAccessToken($accessToken); // Issue and persist new refresh token if given $refreshToken = $this->issueRefreshToken($accessToken); if ($refreshToken !== null) { - $this->getEmitter()->emit(new RequestEvent(RequestEvent::REFRESH_TOKEN_ISSUED, $request)); + $this->getEmitter()->emit(new RequestRefreshTokenEvent(RequestEvent::REFRESH_TOKEN_ISSUED, $request, $refreshToken)); $responseType->setRefreshToken($refreshToken); } @@ -271,8 +274,13 @@ public function validateAuthorizationRequest(ServerRequestInterface $request) $redirectUri = $this->getQueryStringParameter('redirect_uri', $request); if ($redirectUri !== null) { + if (!\is_string($redirectUri)) { + throw OAuthServerException::invalidRequest('redirect_uri'); + } + $this->validateRedirectUri($redirectUri, $client, $request); - } elseif (\is_array($client->getRedirectUri()) && \count($client->getRedirectUri()) !== 1) { + } elseif (empty($client->getRedirectUri()) || + (\is_array($client->getRedirectUri()) && \count($client->getRedirectUri()) !== 1)) { $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); throw OAuthServerException::invalidClient($request); diff --git a/src/Grant/ClientCredentialsGrant.php b/src/Grant/ClientCredentialsGrant.php index b4c096b95..fbc130bcd 100644 --- a/src/Grant/ClientCredentialsGrant.php +++ b/src/Grant/ClientCredentialsGrant.php @@ -13,6 +13,7 @@ use DateInterval; use League\OAuth2\Server\Exception\OAuthServerException; +use League\OAuth2\Server\RequestAccessTokenEvent; use League\OAuth2\Server\RequestEvent; use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; use Psr\Http\Message\ServerRequestInterface; @@ -58,7 +59,7 @@ public function respondToAccessTokenRequest( $accessToken = $this->issueAccessToken($accessTokenTTL, $client, null, $finalizedScopes, $privateClaims); // Send event to emitter - $this->getEmitter()->emit(new RequestEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request)); + $this->getEmitter()->emit(new RequestAccessTokenEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request, $accessToken)); // Inject access token into response type $responseType->setAccessToken($accessToken); diff --git a/src/Grant/ImplicitGrant.php b/src/Grant/ImplicitGrant.php index b532a752d..ffee46566 100644 --- a/src/Grant/ImplicitGrant.php +++ b/src/Grant/ImplicitGrant.php @@ -129,6 +129,10 @@ public function validateAuthorizationRequest(ServerRequestInterface $request) $redirectUri = $this->getQueryStringParameter('redirect_uri', $request); if ($redirectUri !== null) { + if (!\is_string($redirectUri)) { + throw OAuthServerException::invalidRequest('redirect_uri'); + } + $this->validateRedirectUri($redirectUri, $client, $request); } elseif (\is_array($client->getRedirectUri()) && \count($client->getRedirectUri()) !== 1 || empty($client->getRedirectUri())) { @@ -147,6 +151,10 @@ public function validateAuthorizationRequest(ServerRequestInterface $request) $stateParameter = $this->getQueryStringParameter('state', $request); + if ($stateParameter !== null && !\is_string($stateParameter)) { + throw OAuthServerException::invalidRequest('state'); + } + $authorizationRequest = $this->createAuthorizationRequest(); $authorizationRequest->setGrantTypeId($this->getIdentifier()); $authorizationRequest->setClient($client); diff --git a/src/Grant/PasswordGrant.php b/src/Grant/PasswordGrant.php index 51ada3c72..6bbff639e 100644 --- a/src/Grant/PasswordGrant.php +++ b/src/Grant/PasswordGrant.php @@ -17,7 +17,9 @@ use League\OAuth2\Server\Exception\OAuthServerException; use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; use League\OAuth2\Server\Repositories\UserRepositoryInterface; +use League\OAuth2\Server\RequestAccessTokenEvent; use League\OAuth2\Server\RequestEvent; +use League\OAuth2\Server\RequestRefreshTokenEvent; use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; use Psr\Http\Message\ServerRequestInterface; @@ -74,14 +76,14 @@ public function respondToAccessTokenRequest( $finalizedScopes, $privateClaims ); - $this->getEmitter()->emit(new RequestEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request)); + $this->getEmitter()->emit(new RequestAccessTokenEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request, $accessToken)); $responseType->setAccessToken($accessToken); // Issue and persist new refresh token if given $refreshToken = $this->issueRefreshToken($accessToken); if ($refreshToken !== null) { - $this->getEmitter()->emit(new RequestEvent(RequestEvent::REFRESH_TOKEN_ISSUED, $request)); + $this->getEmitter()->emit(new RequestRefreshTokenEvent(RequestEvent::REFRESH_TOKEN_ISSUED, $request, $refreshToken)); $responseType->setRefreshToken($refreshToken); } @@ -100,13 +102,13 @@ protected function validateUser(ServerRequestInterface $request, ClientEntityInt { $username = $this->getRequestParameter('username', $request); - if (\is_null($username)) { + if (!\is_string($username)) { throw OAuthServerException::invalidRequest('username'); } $password = $this->getRequestParameter('password', $request); - if (\is_null($password)) { + if (!\is_string($password)) { throw OAuthServerException::invalidRequest('password'); } @@ -120,7 +122,7 @@ protected function validateUser(ServerRequestInterface $request, ClientEntityInt if ($user instanceof UserEntityInterface === false) { $this->getEmitter()->emit(new RequestEvent(RequestEvent::USER_AUTHENTICATION_FAILED, $request)); - throw OAuthServerException::invalidGrant(); + throw OAuthServerException::invalidCredentials(); } return $user; diff --git a/src/Grant/RefreshTokenGrant.php b/src/Grant/RefreshTokenGrant.php index e53a94098..6340d7e5c 100644 --- a/src/Grant/RefreshTokenGrant.php +++ b/src/Grant/RefreshTokenGrant.php @@ -15,7 +15,9 @@ use Exception; use League\OAuth2\Server\Exception\OAuthServerException; use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; +use League\OAuth2\Server\RequestAccessTokenEvent; use League\OAuth2\Server\RequestEvent; +use League\OAuth2\Server\RequestRefreshTokenEvent; use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; use Psr\Http\Message\ServerRequestInterface; @@ -65,7 +67,9 @@ public function respondToAccessTokenRequest( // Expire old tokens $this->accessTokenRepository->revokeAccessToken($oldRefreshToken['access_token_id']); - $this->refreshTokenRepository->revokeRefreshToken($oldRefreshToken['refresh_token_id']); + if ($this->revokeRefreshTokens) { + $this->refreshTokenRepository->revokeRefreshToken($oldRefreshToken['refresh_token_id']); + } $privateClaims = []; @@ -85,15 +89,17 @@ public function respondToAccessTokenRequest( $scopes, $privateClaims ); - $this->getEmitter()->emit(new RequestEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request)); + $this->getEmitter()->emit(new RequestAccessTokenEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request, $accessToken)); $responseType->setAccessToken($accessToken); // Issue and persist new refresh token if given - $refreshToken = $this->issueRefreshToken($accessToken); + if ($this->revokeRefreshTokens) { + $refreshToken = $this->issueRefreshToken($accessToken); - if ($refreshToken !== null) { - $this->getEmitter()->emit(new RequestEvent(RequestEvent::REFRESH_TOKEN_ISSUED, $request)); - $responseType->setRefreshToken($refreshToken); + if ($refreshToken !== null) { + $this->getEmitter()->emit(new RequestRefreshTokenEvent(RequestEvent::REFRESH_TOKEN_ISSUED, $request, $refreshToken)); + $responseType->setRefreshToken($refreshToken); + } } return $responseType; @@ -110,7 +116,7 @@ public function respondToAccessTokenRequest( protected function validateOldRefreshToken(ServerRequestInterface $request, $clientId) { $encryptedRefreshToken = $this->getRequestParameter('refresh_token', $request); - if (\is_null($encryptedRefreshToken)) { + if (!\is_string($encryptedRefreshToken)) { throw OAuthServerException::invalidRequest('refresh_token'); } diff --git a/src/RedirectUriValidators/RedirectUriValidator.php b/src/RedirectUriValidators/RedirectUriValidator.php new file mode 100644 index 000000000..2cb020801 --- /dev/null +++ b/src/RedirectUriValidators/RedirectUriValidator.php @@ -0,0 +1,114 @@ + + * @copyright Copyright (c) Alex Bilbie + * @license http://mit-license.org/ + * + * @link https://github.com/thephpleague/oauth2-server + */ + +namespace League\OAuth2\Server\RedirectUriValidators; + +class RedirectUriValidator implements RedirectUriValidatorInterface +{ + /** + * @var array + */ + private $allowedRedirectUris; + + /** + * New validator instance for the given uri + * + * @param string|array $allowedRedirectUris + */ + public function __construct($allowedRedirectUri) + { + if (\is_string($allowedRedirectUri)) { + $this->allowedRedirectUris = [$allowedRedirectUri]; + } elseif (\is_array($allowedRedirectUri)) { + $this->allowedRedirectUris = $allowedRedirectUri; + } else { + $this->allowedRedirectUris = []; + } + } + + /** + * Validates the redirect uri. + * + * @param string $redirectUri + * + * @return bool Return true if valid, false otherwise + */ + public function validateRedirectUri($redirectUri) + { + if ($this->isLoopbackUri($redirectUri)) { + return $this->matchUriExcludingPort($redirectUri); + } + + return $this->matchExactUri($redirectUri); + } + + /** + * According to section 7.3 of rfc8252, loopback uris are: + * - "http://127.0.0.1:{port}/{path}" for IPv4 + * - "http://[::1]:{port}/{path}" for IPv6 + * + * @param string $redirectUri + * + * @return bool + */ + private function isLoopbackUri($redirectUri) + { + $parsedUrl = \parse_url($redirectUri); + + return $parsedUrl['scheme'] === 'http' + && (\in_array($parsedUrl['host'], ['127.0.0.1', '[::1]'], true)); + } + + /** + * Find an exact match among allowed uris + * + * @param string $redirectUri + * + * @return bool Return true if an exact match is found, false otherwise + */ + private function matchExactUri($redirectUri) + { + return \in_array($redirectUri, $this->allowedRedirectUris, true); + } + + /** + * Find a match among allowed uris, allowing for different port numbers + * + * @param string $redirectUri + * + * @return bool Return true if a match is found, false otherwise + */ + private function matchUriExcludingPort($redirectUri) + { + $parsedUrl = $this->parseUrlAndRemovePort($redirectUri); + + foreach ($this->allowedRedirectUris as $allowedRedirectUri) { + if ($parsedUrl === $this->parseUrlAndRemovePort($allowedRedirectUri)) { + return true; + } + } + + return false; + } + + /** + * Parse an url like \parse_url, excluding the port + * + * @param string $url + * + * @return array + */ + private function parseUrlAndRemovePort($url) + { + $parsedUrl = \parse_url($url); + unset($parsedUrl['port']); + + return $parsedUrl; + } +} diff --git a/src/RedirectUriValidators/RedirectUriValidatorInterface.php b/src/RedirectUriValidators/RedirectUriValidatorInterface.php new file mode 100644 index 000000000..d039085ab --- /dev/null +++ b/src/RedirectUriValidators/RedirectUriValidatorInterface.php @@ -0,0 +1,22 @@ + + * @copyright Copyright (c) Alex Bilbie + * @license http://mit-license.org/ + * + * @link https://github.com/thephpleague/oauth2-server + */ + +namespace League\OAuth2\Server\RedirectUriValidators; + +interface RedirectUriValidatorInterface +{ + /** + * Validates the redirect uri. + * + * @param string $redirectUri + * + * @return bool Return true if valid, false otherwise + */ + public function validateRedirectUri($redirectUri); +} diff --git a/src/RequestAccessTokenEvent.php b/src/RequestAccessTokenEvent.php new file mode 100644 index 000000000..99d17bf36 --- /dev/null +++ b/src/RequestAccessTokenEvent.php @@ -0,0 +1,40 @@ + + * @copyright Copyright (c) Alex Bilbie + * @license http://mit-license.org/ + * + * @link https://github.com/thephpleague/oauth2-server + */ + +namespace League\OAuth2\Server; + +use League\OAuth2\Server\Entities\AccessTokenEntityInterface; +use Psr\Http\Message\ServerRequestInterface; + +class RequestAccessTokenEvent extends RequestEvent +{ + /** + * @var AccessTokenEntityInterface + */ + private $accessToken; + + /** + * @param string $name + * @param ServerRequestInterface $request + */ + public function __construct($name, ServerRequestInterface $request, AccessTokenEntityInterface $accessToken) + { + parent::__construct($name, $request); + $this->accessToken = $accessToken; + } + + /** + * @return AccessTokenEntityInterface + * @codeCoverageIgnore + */ + public function getAccessToken() + { + return $this->accessToken; + } +} diff --git a/src/RequestRefreshTokenEvent.php b/src/RequestRefreshTokenEvent.php new file mode 100644 index 000000000..0682e57f5 --- /dev/null +++ b/src/RequestRefreshTokenEvent.php @@ -0,0 +1,40 @@ + + * @copyright Copyright (c) Alex Bilbie + * @license http://mit-license.org/ + * + * @link https://github.com/thephpleague/oauth2-server + */ + +namespace League\OAuth2\Server; + +use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; +use Psr\Http\Message\ServerRequestInterface; + +class RequestRefreshTokenEvent extends RequestEvent +{ + /** + * @var RefreshTokenEntityInterface + */ + private $refreshToken; + + /** + * @param string $name + * @param ServerRequestInterface $request + */ + public function __construct($name, ServerRequestInterface $request, RefreshTokenEntityInterface $refreshToken) + { + parent::__construct($name, $request); + $this->refreshToken = $refreshToken; + } + + /** + * @return RefreshTokenEntityInterface + * @codeCoverageIgnore + */ + public function getRefreshToken() + { + return $this->refreshToken; + } +} diff --git a/tests/AuthorizationValidators/BearerTokenValidatorTest.php b/tests/AuthorizationValidators/BearerTokenValidatorTest.php index c95c60531..838d2bbae 100644 --- a/tests/AuthorizationValidators/BearerTokenValidatorTest.php +++ b/tests/AuthorizationValidators/BearerTokenValidatorTest.php @@ -2,33 +2,69 @@ namespace LeagueTests\AuthorizationValidators; +use DateInterval; +use DateTimeImmutable; use Laminas\Diactoros\ServerRequest; -use Lcobucci\JWT\Builder; +use Lcobucci\JWT\Signer\Key\InMemory; +use Lcobucci\JWT\Signer\Rsa\Sha256; use League\OAuth2\Server\AuthorizationValidators\BearerTokenValidator; use League\OAuth2\Server\CryptKey; use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface; use PHPUnit\Framework\TestCase; +use ReflectionClass; class BearerTokenValidatorTest extends TestCase { - public function testThrowExceptionWhenAccessTokenIsNotSigned() + public function testBearerTokenValidatorAcceptsValidToken() { $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); $bearerTokenValidator = new BearerTokenValidator($accessTokenRepositoryMock); $bearerTokenValidator->setPublicKey(new CryptKey('file://' . __DIR__ . '/../Stubs/public.key')); - $unsignedJwt = (new Builder()) + $bearerTokenValidatorReflection = new ReflectionClass(BearerTokenValidator::class); + $jwtConfiguration = $bearerTokenValidatorReflection->getProperty('jwtConfiguration'); + $jwtConfiguration->setAccessible(true); + + $validJwt = $jwtConfiguration->getValue($bearerTokenValidator)->builder() + ->permittedFor('client-id') + ->identifiedBy('token-id') + ->issuedAt(new DateTimeImmutable()) + ->canOnlyBeUsedAfter(new DateTimeImmutable()) + ->expiresAt((new DateTimeImmutable())->add(new DateInterval('PT1H'))) + ->relatedTo('user-id') + ->withClaim('scopes', 'scope1 scope2 scope3 scope4') + ->getToken(new Sha256(), InMemory::file(__DIR__ . '/../Stubs/private.key')); + + $request = (new ServerRequest())->withHeader('authorization', \sprintf('Bearer %s', $validJwt->toString())); + + $validRequest = $bearerTokenValidator->validateAuthorization($request); + + $this->assertArrayHasKey('authorization', $validRequest->getHeaders()); + } + + public function testBearerTokenValidatorRejectsExpiredToken() + { + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + + $bearerTokenValidator = new BearerTokenValidator($accessTokenRepositoryMock); + $bearerTokenValidator->setPublicKey(new CryptKey('file://' . __DIR__ . '/../Stubs/public.key')); + + $bearerTokenValidatorReflection = new ReflectionClass(BearerTokenValidator::class); + $jwtConfiguration = $bearerTokenValidatorReflection->getProperty('jwtConfiguration'); + $jwtConfiguration->setAccessible(true); + + $expiredJwt = $jwtConfiguration->getValue($bearerTokenValidator)->builder() ->permittedFor('client-id') - ->identifiedBy('token-id', true) - ->issuedAt(\time()) - ->canOnlyBeUsedAfter(\time()) - ->expiresAt(\time()) + ->identifiedBy('token-id') + ->issuedAt(new DateTimeImmutable()) + ->canOnlyBeUsedAfter(new DateTimeImmutable()) + ->expiresAt((new DateTimeImmutable())->sub(new DateInterval('PT1H'))) ->relatedTo('user-id') ->withClaim('scopes', 'scope1 scope2 scope3 scope4') - ->getToken(); + ->getToken(new Sha256(), InMemory::file(__DIR__ . '/../Stubs/private.key')); - $request = (new ServerRequest())->withHeader('authorization', \sprintf('Bearer %s', $unsignedJwt)); + $request = (new ServerRequest())->withHeader('authorization', \sprintf('Bearer %s', $expiredJwt->toString())); $this->expectException(\League\OAuth2\Server\Exception\OAuthServerException::class); $this->expectExceptionCode(9); diff --git a/tests/Exception/OAuthServerExceptionTest.php b/tests/Exception/OAuthServerExceptionTest.php index 024911884..38b86d433 100644 --- a/tests/Exception/OAuthServerExceptionTest.php +++ b/tests/Exception/OAuthServerExceptionTest.php @@ -29,6 +29,23 @@ public function testInvalidClientExceptionSetsAuthenticateHeader() } } + public function testInvalidClientExceptionSetsBearerAuthenticateHeader() + { + $serverRequest = (new ServerRequest()) + ->withParsedBody([ + 'client_id' => 'foo', + ]) + ->withAddedHeader('Authorization', 'Bearer fakeauthdetails'); + + try { + $this->issueInvalidClientException($serverRequest); + } catch (OAuthServerException $e) { + $response = $e->generateHttpResponse(new Response()); + + $this->assertEquals(['Bearer realm="OAuth"'], $response->getHeader('WWW-Authenticate')); + } + } + public function testInvalidClientExceptionOmitsAuthenticateHeader() { $serverRequest = (new ServerRequest()) @@ -45,6 +62,23 @@ public function testInvalidClientExceptionOmitsAuthenticateHeader() } } + public function testInvalidClientExceptionOmitsAuthenticateHeaderGivenEmptyAuthorizationHeader() + { + $serverRequest = (new ServerRequest()) + ->withParsedBody([ + 'client_id' => 'foo', + ]) + ->withAddedHeader('Authorization', ''); + + try { + $this->issueInvalidClientException($serverRequest); + } catch (OAuthServerException $e) { + $response = $e->generateHttpResponse(new Response()); + + $this->assertFalse($response->hasHeader('WWW-Authenticate')); + } + } + /** * Issue an invalid client exception * @@ -103,4 +137,11 @@ public function testCanGetRedirectionUri() $this->assertSame('https://example.com/error', $exceptionWithRedirect->getRedirectUri()); } + + public function testInvalidCredentialsIsInvalidGrant() + { + $exception = OAuthServerException::invalidCredentials(); + + $this->assertSame('invalid_grant', $exception->getErrorType()); + } } diff --git a/tests/Grant/AbstractGrantTest.php b/tests/Grant/AbstractGrantTest.php index b4aa392b1..fbb87ae00 100644 --- a/tests/Grant/AbstractGrantTest.php +++ b/tests/Grant/AbstractGrantTest.php @@ -89,6 +89,38 @@ public function testHttpBasicNoColon() $this->assertSame([null, null], $basicAuthMethod->invoke($grantMock, $serverRequest)); } + public function testGetClientCredentialsClientSecretNotAString() + { + $clientRepositoryMock = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); + + /** @var AbstractGrant $grantMock */ + $grantMock = $this->getMockForAbstractClass(AbstractGrant::class); + $grantMock->setClientRepository($clientRepositoryMock); + + $abstractGrantReflection = new \ReflectionClass($grantMock); + + $serverRequest = new ServerRequest( + [], + [], + null, + 'POST', + 'php://input', + [], + [], + [], + [ + 'client_id' => 'client_id', + 'client_secret' => ['not', 'a', 'string'], + ] + ); + $getClientCredentialsMethod = $abstractGrantReflection->getMethod('getClientCredentials'); + $getClientCredentialsMethod->setAccessible(true); + + $this->expectException(\League\OAuth2\Server\Exception\OAuthServerException::class); + + $getClientCredentialsMethod->invoke($grantMock, $serverRequest, true, true); + } + public function testValidateClientPublic() { $client = new ClientEntity(); @@ -261,6 +293,32 @@ public function testValidateClientInvalidRedirectUriArray() $validateClientMethod->invoke($grantMock, $serverRequest, true, true); } + public function testValidateClientMalformedRedirectUri() + { + $client = new ClientEntity(); + $client->setRedirectUri('http://foo/bar'); + $clientRepositoryMock = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); + $clientRepositoryMock->method('getClientEntity')->willReturn($client); + + /** @var AbstractGrant $grantMock */ + $grantMock = $this->getMockForAbstractClass(AbstractGrant::class); + $grantMock->setClientRepository($clientRepositoryMock); + + $abstractGrantReflection = new \ReflectionClass($grantMock); + + $serverRequest = (new ServerRequest())->withParsedBody([ + 'client_id' => 'foo', + 'redirect_uri' => ['not', 'a', 'string'], + ]); + + $validateClientMethod = $abstractGrantReflection->getMethod('validateClient'); + $validateClientMethod->setAccessible(true); + + $this->expectException(\League\OAuth2\Server\Exception\OAuthServerException::class); + + $validateClientMethod->invoke($grantMock, $serverRequest, true, true); + } + public function testValidateClientBadClient() { $clientRepositoryMock = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); @@ -432,13 +490,13 @@ public function testValidateScopes() { $scope = new ScopeEntity(); $scopeRepositoryMock = $this->getMockBuilder(ScopeRepositoryInterface::class)->getMock(); - $scopeRepositoryMock->method('getScopeEntityByIdentifier')->willReturn($scope); + $scopeRepositoryMock->expects($this->exactly(3))->method('getScopeEntityByIdentifier')->willReturn($scope); /** @var AbstractGrant $grantMock */ $grantMock = $this->getMockForAbstractClass(AbstractGrant::class); $grantMock->setScopeRepository($scopeRepositoryMock); - $this->assertEquals([$scope], $grantMock->validateScopes('basic ')); + $this->assertEquals([$scope, $scope, $scope], $grantMock->validateScopes('basic test 0 ')); } public function testValidateScopesBadScope() diff --git a/tests/Grant/AuthCodeGrantTest.php b/tests/Grant/AuthCodeGrantTest.php index 78434495e..7e5cb80cd 100644 --- a/tests/Grant/AuthCodeGrantTest.php +++ b/tests/Grant/AuthCodeGrantTest.php @@ -1135,6 +1135,44 @@ public function testRespondToAccessTokenRequestWithRefreshTokenInsteadOfAuthCode } } + public function testRespondToAccessTokenRequestWithAuthCodeNotAString() + { + $client = new ClientEntity(); + $client->setRedirectUri('http://foo/bar'); + + $clientRepositoryMock = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); + $clientRepositoryMock->method('getClientEntity')->willReturn($client); + + $grant = new AuthCodeGrant( + $this->getMockBuilder(AuthCodeRepositoryInterface::class)->getMock(), + $this->getMockBuilder(RefreshTokenRepositoryInterface::class)->getMock(), + new DateInterval('PT10M') + ); + + $grant->setClientRepository($clientRepositoryMock); + $grant->setEncryptionKey($this->cryptStub->getKey()); + + $request = new ServerRequest( + [], + [], + null, + 'POST', + 'php://input', + [], + [], + [], + [ + 'grant_type' => 'authorization_code', + 'client_id' => 'foo', + 'redirect_uri' => 'http://foo/bar', + 'code' => ['not', 'a', 'string'], + ] + ); + + $this->expectException(\League\OAuth2\Server\Exception\OAuthServerException::class); + $grant->respondToAccessTokenRequest($request, new StubResponseType(), new DateInterval('PT10M')); + } + public function testRespondToAccessTokenRequestExpiredCode() { $client = new ClientEntity(); @@ -1739,8 +1777,16 @@ public function testAuthCodeRepositoryUniqueConstraintCheck() $authCodeRepository = $this->getMockBuilder(AuthCodeRepositoryInterface::class)->getMock(); $authCodeRepository->method('getNewAuthCode')->willReturn(new AuthCodeEntity()); - $authCodeRepository->expects($this->at(0))->method('persistNewAuthCode')->willThrowException(UniqueTokenIdentifierConstraintViolationException::create()); - $authCodeRepository->expects($this->at(1))->method('persistNewAuthCode'); + $matcher = $this->exactly(2); + + $authCodeRepository + ->expects($matcher) + ->method('persistNewAuthCode') + ->willReturnCallback(function () use ($matcher) { + if ($matcher->getInvocationCount() === 1) { + throw UniqueTokenIdentifierConstraintViolationException::create(); + } + }); $grant = new AuthCodeGrant( $authCodeRepository, @@ -1821,8 +1867,17 @@ public function testRefreshTokenRepositoryUniqueConstraintCheck() $refreshTokenRepositoryMock = $this->getMockBuilder(RefreshTokenRepositoryInterface::class)->getMock(); $refreshTokenRepositoryMock->method('getNewRefreshToken')->willReturn(new RefreshTokenEntity()); - $refreshTokenRepositoryMock->expects($this->at(0))->method('persistNewRefreshToken')->willThrowException(UniqueTokenIdentifierConstraintViolationException::create()); - $refreshTokenRepositoryMock->expects($this->at(1))->method('persistNewRefreshToken'); + + $matcher = $this->exactly(2); + + $refreshTokenRepositoryMock + ->expects($matcher) + ->method('persistNewRefreshToken') + ->willReturnCallback(function () use ($matcher) { + if ($matcher->getInvocationCount() === 1) { + throw UniqueTokenIdentifierConstraintViolationException::create(); + } + }); $grant = new AuthCodeGrant( $this->getMockBuilder(AuthCodeRepositoryInterface::class)->getMock(), diff --git a/tests/Grant/ImplicitGrantTest.php b/tests/Grant/ImplicitGrantTest.php index a22977a34..d5bb0792b 100644 --- a/tests/Grant/ImplicitGrantTest.php +++ b/tests/Grant/ImplicitGrantTest.php @@ -277,8 +277,17 @@ public function testAccessTokenRepositoryUniqueConstraintCheck() /** @var AccessTokenRepositoryInterface|\PHPUnit\Framework\MockObject\MockObject $accessTokenRepositoryMock */ $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); $accessTokenRepositoryMock->method('getNewToken')->willReturn($accessToken); - $accessTokenRepositoryMock->expects($this->at(0))->method('persistNewAccessToken')->willThrowException(UniqueTokenIdentifierConstraintViolationException::create()); - $accessTokenRepositoryMock->expects($this->at(1))->method('persistNewAccessToken')->willReturnSelf(); + + $matcher = $this->exactly(2); + + $accessTokenRepositoryMock + ->expects($matcher) + ->method('persistNewAccessToken') + ->willReturnCallback(function () use ($matcher) { + if ($matcher->getInvocationCount() === 1) { + throw UniqueTokenIdentifierConstraintViolationException::create(); + } + }); $scopeRepositoryMock = $this->getMockBuilder(ScopeRepositoryInterface::class)->getMock(); $scopeRepositoryMock->method('finalizeScopes')->willReturnArgument(0); diff --git a/tests/Grant/PasswordGrantTest.php b/tests/Grant/PasswordGrantTest.php index aef864e7d..f5703035f 100644 --- a/tests/Grant/PasswordGrantTest.php +++ b/tests/Grant/PasswordGrantTest.php @@ -216,7 +216,7 @@ public function testRespondToRequestBadCredentials() $responseType = new StubResponseType(); $this->expectException(\League\OAuth2\Server\Exception\OAuthServerException::class); - $this->expectExceptionCode(10); + $this->expectExceptionCode(6); $grant->respondToAccessTokenRequest($serverRequest, $responseType, new DateInterval('PT5M')); } diff --git a/tests/Grant/RefreshTokenGrantTest.php b/tests/Grant/RefreshTokenGrantTest.php index 8febaa2f7..75b96415e 100644 --- a/tests/Grant/RefreshTokenGrantTest.php +++ b/tests/Grant/RefreshTokenGrantTest.php @@ -19,6 +19,7 @@ use LeagueTests\Stubs\RefreshTokenEntity; use LeagueTests\Stubs\ScopeEntity; use LeagueTests\Stubs\StubResponseType; +use PHPUnit\Framework\Assert; use PHPUnit\Framework\TestCase; class RefreshTokenGrantTest extends TestCase @@ -74,6 +75,7 @@ public function testRespondToRequest() $grant->setAccessTokenRepository($accessTokenRepositoryMock); $grant->setEncryptionKey($this->cryptStub->getKey()); $grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); + $grant->revokeRefreshTokens(true); $oldRefreshToken = $this->cryptStub->doEncrypt( \json_encode( @@ -189,6 +191,7 @@ public function testRespondToReducedScopes() $grant->setScopeRepository($scopeRepositoryMock); $grant->setEncryptionKey($this->cryptStub->getKey()); $grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); + $grant->revokeRefreshTokens(true); $oldRefreshToken = $this->cryptStub->doEncrypt( \json_encode( @@ -483,7 +486,6 @@ public function testRespondToRequestFinalizeScopes() $client->setIdentifier('foo'); $client->setRedirectUri('http://foo/bar'); - $clientRepositoryMock = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); $clientRepositoryMock->method('getClientEntity')->willReturn($client); @@ -549,4 +551,118 @@ public function testRespondToRequestFinalizeScopes() $grant->respondToAccessTokenRequest($serverRequest, $responseType, new DateInterval('PT5M')); } + + public function testRevokedRefreshToken() + { + $refreshTokenId = 'foo'; + + $client = new ClientEntity(); + $client->setIdentifier('foo'); + $client->setRedirectUri('http://foo/bar'); + + $clientRepositoryMock = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); + $clientRepositoryMock->method('getClientEntity')->willReturn($client); + + $scopeEntity = new ScopeEntity(); + $scopeEntity->setIdentifier('foo'); + + $scopeRepositoryMock = $this->getMockBuilder(ScopeRepositoryInterface::class)->getMock(); + $scopeRepositoryMock->method('getScopeEntityByIdentifier')->willReturn($scopeEntity); + + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $accessTokenRepositoryMock->method('getNewToken')->willReturn(new AccessTokenEntity()); + $accessTokenRepositoryMock->expects($this->once())->method('persistNewAccessToken')->willReturnSelf(); + + $refreshTokenRepositoryMock = $this->getMockBuilder(RefreshTokenRepositoryInterface::class)->getMock(); + $refreshTokenRepositoryMock->method('isRefreshTokenRevoked') + ->will($this->onConsecutiveCalls(false, true)); + $refreshTokenRepositoryMock->expects($this->once())->method('revokeRefreshToken')->with($this->equalTo($refreshTokenId)); + + $oldRefreshToken = $this->cryptStub->doEncrypt( + \json_encode( + [ + 'client_id' => 'foo', + 'refresh_token_id' => $refreshTokenId, + 'access_token_id' => 'abcdef', + 'scopes' => ['foo'], + 'user_id' => 123, + 'expire_time' => \time() + 3600, + ] + ) + ); + + $serverRequest = (new ServerRequest())->withParsedBody([ + 'client_id' => 'foo', + 'client_secret' => 'bar', + 'refresh_token' => $oldRefreshToken, + 'scope' => ['foo'], + ]); + + $grant = new RefreshTokenGrant($refreshTokenRepositoryMock); + $grant->setClientRepository($clientRepositoryMock); + $grant->setScopeRepository($scopeRepositoryMock); + $grant->setAccessTokenRepository($accessTokenRepositoryMock); + $grant->setEncryptionKey($this->cryptStub->getKey()); + $grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); + $grant->revokeRefreshTokens(true); + $grant->respondToAccessTokenRequest($serverRequest, new StubResponseType(), new DateInterval('PT5M')); + + Assert::assertTrue($refreshTokenRepositoryMock->isRefreshTokenRevoked($refreshTokenId)); + } + + public function testUnrevokedRefreshToken() + { + $refreshTokenId = 'foo'; + + $client = new ClientEntity(); + $client->setIdentifier('foo'); + $client->setRedirectUri('http://foo/bar'); + + $clientRepositoryMock = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); + $clientRepositoryMock->method('getClientEntity')->willReturn($client); + + $scopeEntity = new ScopeEntity(); + $scopeEntity->setIdentifier('foo'); + + $scopeRepositoryMock = $this->getMockBuilder(ScopeRepositoryInterface::class)->getMock(); + $scopeRepositoryMock->method('getScopeEntityByIdentifier')->willReturn($scopeEntity); + + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $accessTokenRepositoryMock->method('getNewToken')->willReturn(new AccessTokenEntity()); + $accessTokenRepositoryMock->expects($this->once())->method('persistNewAccessToken')->willReturnSelf(); + + $refreshTokenRepositoryMock = $this->getMockBuilder(RefreshTokenRepositoryInterface::class)->getMock(); + $refreshTokenRepositoryMock->method('isRefreshTokenRevoked')->willReturn(false); + $refreshTokenRepositoryMock->expects($this->never())->method('revokeRefreshToken'); + + $oldRefreshToken = $this->cryptStub->doEncrypt( + \json_encode( + [ + 'client_id' => 'foo', + 'refresh_token_id' => $refreshTokenId, + 'access_token_id' => 'abcdef', + 'scopes' => ['foo'], + 'user_id' => 123, + 'expire_time' => \time() + 3600, + ] + ) + ); + + $serverRequest = (new ServerRequest())->withParsedBody([ + 'client_id' => 'foo', + 'client_secret' => 'bar', + 'refresh_token' => $oldRefreshToken, + 'scope' => ['foo'], + ]); + + $grant = new RefreshTokenGrant($refreshTokenRepositoryMock); + $grant->setClientRepository($clientRepositoryMock); + $grant->setScopeRepository($scopeRepositoryMock); + $grant->setAccessTokenRepository($accessTokenRepositoryMock); + $grant->setEncryptionKey($this->cryptStub->getKey()); + $grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); + $grant->respondToAccessTokenRequest($serverRequest, new StubResponseType(), new DateInterval('PT5M')); + + Assert::assertFalse($refreshTokenRepositoryMock->isRefreshTokenRevoked($refreshTokenId)); + } } diff --git a/tests/RedirectUriValidators/RedirectUriValidatorTest.php b/tests/RedirectUriValidators/RedirectUriValidatorTest.php new file mode 100644 index 000000000..0c516cda0 --- /dev/null +++ b/tests/RedirectUriValidators/RedirectUriValidatorTest.php @@ -0,0 +1,75 @@ +assertFalse( + $validator->validateRedirectUri($invalidRedirectUri), + 'Non loopback URI must match in every part' + ); + } + + public function testValidNonLoopbackUri() + { + $validator = new RedirectUriValidator([ + 'https://example.com:8443/endpoint', + 'https://example.com/different/endpoint', + ]); + + $validRedirectUri = 'https://example.com:8443/endpoint'; + + $this->assertTrue( + $validator->validateRedirectUri($validRedirectUri), + 'Redirect URI must be valid when matching in every part' + ); + } + + public function testInvalidLoopbackUri() + { + $validator = new RedirectUriValidator('http://127.0.0.1:8443/endpoint'); + + $invalidRedirectUri = 'http://127.0.0.1:8443/different/endpoint'; + + $this->assertFalse( + $validator->validateRedirectUri($invalidRedirectUri), + 'Valid loopback redirect URI can change only the port number' + ); + } + + public function testValidLoopbackUri() + { + $validator = new RedirectUriValidator('http://127.0.0.1:8443/endpoint'); + + $validRedirectUri = 'http://127.0.0.1:8080/endpoint'; + + $this->assertTrue( + $validator->validateRedirectUri($validRedirectUri), + 'Loopback redirect URI can change the port number' + ); + } + + public function testValidIpv6LoopbackUri() + { + $validator = new RedirectUriValidator('http://[::1]:8443/endpoint'); + + $validRedirectUri = 'http://[::1]:8080/endpoint'; + + $this->assertTrue( + $validator->validateRedirectUri($validRedirectUri), + 'Loopback redirect URI can change the port number' + ); + } +} diff --git a/tests/ResponseTypes/BearerResponseTypeTest.php b/tests/ResponseTypes/BearerResponseTypeTest.php index 9574e3e41..45e95f372 100644 --- a/tests/ResponseTypes/BearerResponseTypeTest.php +++ b/tests/ResponseTypes/BearerResponseTypeTest.php @@ -178,7 +178,7 @@ public function testDetermineAccessTokenInHeaderInvalidJWT() $accessToken = new AccessTokenEntity(); $accessToken->setIdentifier('abcdef'); $accessToken->setUserIdentifier(123); - $accessToken->setExpiryDateTime((new DateTimeImmutable())->add(new DateInterval('PT1H'))); + $accessToken->setExpiryDateTime((new DateTimeImmutable())->sub(new DateInterval('PT1H'))); $accessToken->setClient($client); $accessToken->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); @@ -196,7 +196,7 @@ public function testDetermineAccessTokenInHeaderInvalidJWT() $authorizationValidator = new BearerTokenValidator($accessTokenRepositoryMock); $authorizationValidator->setPublicKey(new CryptKey('file://' . __DIR__ . '/../Stubs/public.key')); - $request = (new ServerRequest())->withHeader('authorization', \sprintf('Bearer %s', $json->access_token . 'foo')); + $request = (new ServerRequest())->withHeader('authorization', \sprintf('Bearer %s', $json->access_token)); try { $authorizationValidator->validateAuthorization($request); @@ -293,7 +293,7 @@ public function testDetermineMissingBearerInHeader() $authorizationValidator->validateAuthorization($request); } catch (OAuthServerException $e) { $this->assertEquals( - 'Error while decoding to JSON', + 'Error while decoding from JSON', $e->getHint() ); } diff --git a/tests/Utils/CryptKeyTest.php b/tests/Utils/CryptKeyTest.php index 667daaf20..b9c53b660 100644 --- a/tests/Utils/CryptKeyTest.php +++ b/tests/Utils/CryptKeyTest.php @@ -23,7 +23,7 @@ public function testKeyCreation() $this->assertEquals('secret', $key->getPassPhrase()); } - public function testKeyFileCreation() + public function testKeyString() { $keyContent = \file_get_contents(__DIR__ . '/../Stubs/public.key'); @@ -34,8 +34,8 @@ public function testKeyFileCreation() $key = new CryptKey($keyContent); $this->assertEquals( - 'file://' . \sys_get_temp_dir() . '/' . \sha1($keyContent) . '.key', - $key->getKeyPath() + $keyContent, + $key->getKeyContents() ); $keyContent = \file_get_contents(__DIR__ . '/../Stubs/private.key.crlf'); @@ -47,23 +47,92 @@ public function testKeyFileCreation() $key = new CryptKey($keyContent); $this->assertEquals( - 'file://' . \sys_get_temp_dir() . '/' . \sha1($keyContent) . '.key', - $key->getKeyPath() + $keyContent, + $key->getKeyContents() ); } + public function testUnsupportedKeyType() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Unable to read key'); + + try { + // Create the keypair + $res = \openssl_pkey_new([ + 'digest_alg' => 'sha512', + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_DSA, + ]); + // Get private key + \openssl_pkey_export($res, $keyContent, 'mystrongpassword'); + $path = self::generateKeyPath($keyContent); + + new CryptKey($keyContent, 'mystrongpassword'); + } finally { + if (isset($path)) { + @\unlink($path); + } + } + } + + public function testECKeyType() + { + try { + // Create the keypair + $res = \openssl_pkey_new([ + 'digest_alg' => 'sha512', + 'curve_name' => 'prime256v1', + 'private_key_type' => OPENSSL_KEYTYPE_EC, + ]); + // Get private key + \openssl_pkey_export($res, $keyContent, 'mystrongpassword'); + + $key = new CryptKey($keyContent, 'mystrongpassword'); + + $this->assertEquals('', $key->getKeyPath()); + $this->assertEquals('mystrongpassword', $key->getPassPhrase()); + } catch (\Throwable $e) { + $this->fail('The EC key was not created'); + } finally { + if (isset($path)) { + @\unlink($path); + } + } + } + + public function testRSAKeyType() + { + try { + // Create the keypair + $res = \openssl_pkey_new([ + 'digest_alg' => 'sha512', + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + // Get private key + \openssl_pkey_export($res, $keyContent, 'mystrongpassword'); + + $key = new CryptKey($keyContent, 'mystrongpassword'); + + $this->assertEquals('', $key->getKeyPath()); + $this->assertEquals('mystrongpassword', $key->getPassPhrase()); + } catch (\Throwable $e) { + $this->fail('The RSA key was not created'); + } finally { + if (isset($path)) { + @\unlink($path); + } + } + } + /** - * Test whether we get a RuntimeException if a PCRE error is encountered. + * @param string $keyContent * - * @link https://www.php.net/manual/en/function.preg-last-error.php + * @return string */ - public function testPcreErrorExceptions() + private static function generateKeyPath($keyContent) { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessageMatches('/^PCRE error/'); - - new class('foobar foobar foobar') extends CryptKey { - const RSA_KEY_PATTERN = '/(?:\D+|<\d+>)*[!?]/'; - }; + return 'file://' . \sys_get_temp_dir() . '/' . \sha1($keyContent) . '.key'; } }