From 0884ca3ab0c8a9d17be511e05f5c4a812badbe7a Mon Sep 17 00:00:00 2001 From: noguchi-hiroshi Date: Fri, 19 Aug 2022 20:15:17 +0900 Subject: [PATCH] :tada: Initial commit --- .github/logo.png | Bin 0 -> 9499 bytes .github/pull_request_template.md | 18 + .github/workflows/ci.yml | 24 + .github/workflows/static.yml | 26 + .gitignore | 21 + .php-cs-fixer.php | 33 + LICENSE | 21 + README.md | 59 ++ SECURITY.md | 22 + composer.json | 45 + examples/README.md | 50 + examples/public/.htaccess | 2 + examples/public/index.php | 175 ++++ examples/public/style.css | 224 +++++ examples/settings.php | 16 + examples/views/error.php | 40 + examples/views/index.php | 43 + examples/views/token.php | 100 ++ examples/views/userinfo.php | 45 + phpstan.neon.dist | 4 + phpunit.xml.dist | 18 + src/Client.php | 242 +++++ src/ClientMetadata.php | 91 ++ src/Constant/GrantType.php | 15 + src/Constant/ResponseType.php | 13 + src/Constant/TokenType.php | 14 + src/Exception/Base64Exception.php | 11 + src/Exception/InvalidResponseException.php | 11 + src/Exception/InvalidTokenException.php | 11 + src/Exception/JsonErrorException.php | 11 + src/Exception/NotFoundException.php | 11 + src/Exception/OidcClientException.php | 11 + src/Jwx/Jwk.php | 105 ++ src/Jwx/JwkSet.php | 43 + src/Jwx/Jws.php | 195 ++++ .../AuthenticationRequestProperty.php | 253 +++++ src/Property/ExchangeProperty.php | 185 ++++ src/Property/RefreshProperty.php | 155 +++ src/Provider.php | 102 ++ src/Token.php | 277 +++++ src/Util/Base64Url.php | 39 + src/Util/FilterInput.php | 37 + src/Util/Json.php | 49 + src/Util/Pkce.php | 38 + src/Util/Random.php | 22 + src/Util/RedirectResponse.php | 63 ++ src/Util/ScopeBuilder.php | 101 ++ tests/ClientMetadataTest.php | 68 ++ tests/ClientTest.php | 950 ++++++++++++++++++ tests/Fixture/Loader.php | 24 + tests/Fixture/MockHttpClient.php | 54 + tests/Fixture/assets/jwks.json | 12 + tests/Fixture/assets/test1_rsa.pub | 9 + tests/Fixture/assets/test2_rsa.pub | 9 + tests/Jwx/JwkSetTest.php | 201 ++++ tests/Jwx/JwkTest.php | 41 + tests/Jwx/JwsTest.php | 165 +++ .../AuthenticationRequestPropertyTest.php | 260 +++++ tests/Property/ExchangePropertyTest.php | 372 +++++++ tests/Property/RefreshPropertyTest.php | 420 ++++++++ tests/ProviderTest.php | 134 +++ tests/TokenTest.php | 378 +++++++ tests/Util/Base64UrlTest.php | 95 ++ tests/Util/JsonTest.php | 118 +++ tests/Util/PkceTest.php | 81 ++ tests/Util/ScopeBuilderTest.php | 133 +++ vendor-bin/php-cs-fixer/composer.json | 5 + vendor-bin/phpstan/composer.json | 5 + 68 files changed, 6625 insertions(+) create mode 100644 .github/logo.png create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/static.yml create mode 100644 .gitignore create mode 100644 .php-cs-fixer.php create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 composer.json create mode 100644 examples/README.md create mode 100644 examples/public/.htaccess create mode 100644 examples/public/index.php create mode 100644 examples/public/style.css create mode 100644 examples/settings.php create mode 100644 examples/views/error.php create mode 100644 examples/views/index.php create mode 100644 examples/views/token.php create mode 100644 examples/views/userinfo.php create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml.dist create mode 100644 src/Client.php create mode 100644 src/ClientMetadata.php create mode 100644 src/Constant/GrantType.php create mode 100644 src/Constant/ResponseType.php create mode 100644 src/Constant/TokenType.php create mode 100644 src/Exception/Base64Exception.php create mode 100644 src/Exception/InvalidResponseException.php create mode 100644 src/Exception/InvalidTokenException.php create mode 100644 src/Exception/JsonErrorException.php create mode 100644 src/Exception/NotFoundException.php create mode 100644 src/Exception/OidcClientException.php create mode 100644 src/Jwx/Jwk.php create mode 100644 src/Jwx/JwkSet.php create mode 100644 src/Jwx/Jws.php create mode 100644 src/Property/AuthenticationRequestProperty.php create mode 100644 src/Property/ExchangeProperty.php create mode 100644 src/Property/RefreshProperty.php create mode 100644 src/Provider.php create mode 100644 src/Token.php create mode 100644 src/Util/Base64Url.php create mode 100644 src/Util/FilterInput.php create mode 100644 src/Util/Json.php create mode 100644 src/Util/Pkce.php create mode 100644 src/Util/Random.php create mode 100644 src/Util/RedirectResponse.php create mode 100644 src/Util/ScopeBuilder.php create mode 100644 tests/ClientMetadataTest.php create mode 100644 tests/ClientTest.php create mode 100644 tests/Fixture/Loader.php create mode 100644 tests/Fixture/MockHttpClient.php create mode 100644 tests/Fixture/assets/jwks.json create mode 100644 tests/Fixture/assets/test1_rsa.pub create mode 100644 tests/Fixture/assets/test2_rsa.pub create mode 100644 tests/Jwx/JwkSetTest.php create mode 100644 tests/Jwx/JwkTest.php create mode 100644 tests/Jwx/JwsTest.php create mode 100644 tests/Property/AuthenticationRequestPropertyTest.php create mode 100644 tests/Property/ExchangePropertyTest.php create mode 100644 tests/Property/RefreshPropertyTest.php create mode 100644 tests/ProviderTest.php create mode 100644 tests/TokenTest.php create mode 100644 tests/Util/Base64UrlTest.php create mode 100644 tests/Util/JsonTest.php create mode 100644 tests/Util/PkceTest.php create mode 100644 tests/Util/ScopeBuilderTest.php create mode 100644 vendor-bin/php-cs-fixer/composer.json create mode 100644 vendor-bin/phpstan/composer.json diff --git a/.github/logo.png b/.github/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..1c8f3c8ad9a0d61c948ec9e8b5cefc63da539eff GIT binary patch literal 9499 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 literal 0 HcmV?d00001 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..6002191 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +# 概要 + +Issue: {IssueURL or Issue ID} + +# 実装内容 + +- [ ] 例: Provider.php のバリデーション処理追加 + +# スクリーンショット + + +# QA +- [ ] 例: xxx 機能のテスト追加 +- [ ] 例: xxx 機能の疎通確認 + +# セキュリティチェック +- [ ] 機密情報静的解析ツールの実施 +- [ ] 脆弱性ツールの実施 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..07074d5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: ci +on: [push] +jobs: + phpunit: + name: PHPUnit / PHP ${{ matrix.php-versions }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-versions: [ '7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1' ] + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + + - name: Download dependencies + run: composer install --no-progress --prefer-dist --optimize-autoloader + + - name: Test + run: composer test diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml new file mode 100644 index 0000000..0aa6f4f --- /dev/null +++ b/.github/workflows/static.yml @@ -0,0 +1,26 @@ +name: static +on: [push] +jobs: + static: + name: PHP Static Analyse + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup + uses: shivammathur/setup-php@v2 + with: + php-version: '7.4' + + - name: Download dependencies + run: composer update --no-interaction --no-progress + + - name: Download bin dependencies + run: composer bin all update --no-interaction --no-progress + + - name: Lint + run: composer lint + + - name: Stan + run: composer stan diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ceb16a --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# OS +.DS_Store + +# IDE +.idea +.vscode + +# Cache +.dccache +*.cache + +# Composer +vendor/ +composer.phar +composer.lock + +# PHP +/phpstan.nexon +/phpunit.xml +build/ +artifacts/ diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php new file mode 100644 index 0000000..ed095ce --- /dev/null +++ b/.php-cs-fixer.php @@ -0,0 +1,33 @@ +in(__DIR__ . '/src') + ->in(__DIR__ . '/examples') + ->in(__DIR__ . '/tests'); + +$config = new PhpCsFixer\Config(); +return $config + ->setRules([ + '@PSR2' => true, + 'blank_line_after_opening_tag' => false, + 'linebreak_after_opening_tag' => false, + 'no_superfluous_phpdoc_tags' => false, + 'array_syntax' => [ + 'syntax' => 'short', + ], + 'binary_operator_spaces' => [ + 'operators' => [ + '=>' => 'align', + '=' => 'single_space', + ], + ], + 'simplified_null_return' => true, + 'blank_line_after_namespace' => true, + 'function_typehint_space' => true, + 'multiline_comment_opening_closing' => true, + 'no_unused_imports' => true, + 'single_line_after_imports' => true, + 'fully_qualified_strict_types' => true, + ]) + ->setUsingCache(false) + ->setFinder($finder); diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ffd2054 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 GameWith + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ccefd75 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +

+ +

+ +# GameWith OIDC SDK For PHP +[![ci](https://github.com/GameWith/gamewith-oidc-sdk-for-php/actions/workflows/ci.yml/badge.svg)](https://github.com/GameWith/gamewith-oidc-sdk-for-php/actions/workflows/ci.yml) + +GameWith アカウント連携の PHP SDK です。[OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) に基づいて実装しています。 + +現在は **Authorization Code Flow** のみ対応しています。 + +## インストール + +事前に Composer をインストールしていただく必要が御座います。 +以下のリンクからダウンロードおよびインストールのセットアップをしてください。 + +[Download Composer](https://getcomposer.org/download) + +composer.json ファイルに以下の内容を定義してください。 + +```json +{ + "require": { + "gamewith/gamewith-oidc-sdk": "^1.0" + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/gamewith/gamewith-oidc-sdk-for-php" + } + ] +} +``` + +最後に以下のコマンドを実行すると GameWith OIDC SDK のインストールが完了いたします。 + +```console +$ composer install +``` + +## 利用方法 + +実装サンプルがありますので、[こちらのリンク](./examples)からご参照ください。 + +## PHP サポートバージョン + +| PHP バージョン | +| --- | +| 7.0 | +| 7.1 | +| 7.2 | +| 7.3 | +| 7.4 | +| 8.0 | +| 8.1 | + +## ライセンス + +GameWith OIDC SDK For PHP は MIT ライセンスになります。詳細は [LICENSE](./LICENSE) をご参照ください。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7a40bb5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,22 @@ +# セキュリティポリシー + +## サポートバージョン + +GameWith OIDC SDK のセキュリティサポートバージョン一覧です。 + +| Version | Supported | +| ------- | ------------------ | +| 1.x | :white_check_mark: | + +:white_check_mark: になっているバージョンはセキュリティサポート対象です。 + +:x: になっているバージョンはセキュリティサポート対象外です。セキュリティサポート対象のバージョンにアップデート推奨いたします。 + +## 脆弱性のご報告について +当リポジトリで脆弱性を発見された方は、こちらの[お問い合わせ(その他)](https://gamewith.jp/inform/feedback)からご報告いただけると幸いです。 + +**Issues など公開されている場でのご報告は、お控え願います。** + +## その他 +GameWith OIDC SDK で利用している外部パッケージに関しましては、比較的広域でバージョンを指定しています。 +そのため、各外部パッケージのバージョンアップデートは各自行っていただきますよう願います。 diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..d96ae35 --- /dev/null +++ b/composer.json @@ -0,0 +1,45 @@ +{ + "name": "gamewith/gamewith-oidc-sdk", + "version": "1.0.0", + "authors": [ + { + "name": "GameWith", + "email": "service-dev@gamewith.co.jp" + } + ], + "scripts": { + "test": "phpunit", + "lint": "php-cs-fixer fix --dry-run --diff", + "lintfix": "php-cs-fixer fix --diff --config .php-cs-fixer.php", + "stan": "phpstan analyse -c phpstan.neon.dist" + }, + "require": { + "php": ">=7.0", + "ext-json": "*", + "ext-curl": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^6|^7", + "phpseclib/phpseclib": "^3.0" + }, + "autoload": { + "psr-4": { + "GameWith\\Oidc\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "GameWith\\Oidc\\Tests\\": "tests/" + } + }, + "require-dev": { + "phpunit/phpunit": "^6|^7|^8|^9", + "mockery/mockery": "^1", + "yoast/phpunit-polyfills": "^1", + "bamarni/composer-bin-plugin": "^1" + }, + "config": { + "allow-plugins": { + "bamarni/composer-bin-plugin": true + } + } +} diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..641f03b --- /dev/null +++ b/examples/README.md @@ -0,0 +1,50 @@ +# 実装サンプル + +GameWith アカウント連携の PHP SDK を利用した実装サンプルです。 + +## ディレクトリ構成 + +``` +├── public # ローカルサーバの起動対象ディレクトリです。 +│   ├── index.php # 各機能のルーティングおよび機能実装をしています。 +│   └── style.css +├── settings.php # クライアント情報・接続情報などの設定をします。 +└── views # 各機能の view を定義しています。 + ├── error.php + ├── index.php + ├── token.php + └── userinfo.php +``` + +## セットアップ + +**settings.php** ファイルの設定項目を変更してください。 + +```php +return [ + 'client' => [ + 'client_id' => '[提供された client_id を入力してください]', + 'client_secret' => '[提供された client_secret を入力してください]', + 'redirect_uri' => '[ご登録された redirect_uri を入力してください]' + ], + 'provider' => [ + // トークン発行者 + 'issuer' => '[提供された issuer を入力してください]', + // 認証リクエストのエンドポイント + 'authorization_endpoint' => '[提供された authorization_endpoint を入力してください]', + // トークンリクエストのエンドポイント + 'token_endpoint' => '[提供された token_endpoint を入力してください]', + // ユーザー情報リクエストのエンドポイント + 'userinfo_endpoint' => '[提供された userinfo_endpoint を入力してください]', + // Jwks 取得のエンドポイント + 'jwks_endpoint' => '[提供された jwks_endpoint を入力してください]', + ] +]; +``` + +## 起動方法 + +```console +$ cd ./public +$ php -S localhost:8863 +``` diff --git a/examples/public/.htaccess b/examples/public/.htaccess new file mode 100644 index 0000000..a1b7467 --- /dev/null +++ b/examples/public/.htaccess @@ -0,0 +1,2 @@ +RewriteEngine on +RewriteRule ^(.*)$ index.php [L] \ No newline at end of file diff --git a/examples/public/index.php b/examples/public/index.php new file mode 100644 index 0000000..f94ab4d --- /dev/null +++ b/examples/public/index.php @@ -0,0 +1,175 @@ + [ + 'GET' => function () { + render('index'); + } + ], + // 機能: 認証リクエスト必要なパラメータをセットして認証または同意ページに遷移します + '/authorize' => [ + 'GET' => function () use ($settings) { + $client = getClient($settings); + $property = new AuthenticationRequestProperty(); + $codeVerifier = Pkce::generateCodeVerifier(); + $property->addScope('openid', 'profile') + ->setMaxAge(600) + ->setState(Random::str()) + ->setNonce(Random::str()) + ->setCodeChallenge( + Pkce::createCodeChallenge($codeVerifier) + ); + $_SESSION['code_verifier'] = $codeVerifier; + $_SESSION['state'] = $property->getState(); + $_SESSION['nonce'] = $property->getNonce(); + $client->sendAuthenticationRequest($property)->redirect(); + }, + ], + // 機能: 認証リクエストの結果を受け取り、トークン発行 + '/callback' => [ + 'GET' => function () use ($settings) { + $state = $_SESSION['state'] ?? null; + $codeVerifier = $_SESSION['code_verifier'] ?? null; + $nonce = $_SESSION['nonce'] ?? null; + + foreach (['state', 'code_verifier', 'nonce'] as $key) { + if (array_key_exists($key, $_SESSION)) { + unset($_SESSION[$key]); + } + } + + $client = getClient($settings); + + $code = $client->receiveAuthenticationRequest($state); + + $property = new ExchangeProperty($code); + $property->addScope('openid', 'profile'); + if (is_string($codeVerifier)) { + $property->setCodeVerifier($codeVerifier); + } + $token = $client->exchange($property); + + $_SESSION['access_token'] = $token->getAccessToken(); + $_SESSION['refresh_token'] = $token->getRefreshToken(); + + $idToken = null; + if (!is_null($token->getIdToken())) { + $idToken = $token->parseIdToken($nonce); + } + render('token', [ + 'title' => 'コールバック&トークン発行', + 'token' => $token, + 'idToken' => $idToken + ]); + } + ], + // 機能: ユーザー情報リクエストAPI にリクエストし、レスポンスを表示する + '/userinfo' => [ + 'GET' => function () use ($settings) { + if (!isset($_SESSION['access_token'])) { + throw new \OutOfBoundsException('access_token is invalid'); + } + $client = getClient($settings); + $userinfo = $client->userInfoRequest($_SESSION['access_token']); + render('userinfo', [ + 'userinfo' => $userinfo + ]); + }, + ], + // 機能: リフレッシュリクエストAPI にリクエストし、トークンを更新する + '/refresh' => [ + 'GET' => function () use ($settings) { + if (!isset($_SESSION['refresh_token'])) { + throw new \OutOfBoundsException('refresh_token is invalid'); + } + $client = getClient($settings); + $property = new RefreshProperty($_SESSION['refresh_token']); + $property->addScope('openid', 'profile'); + $token = $client->refresh($property); + $idToken = null; + if (!is_null($token->getIdToken())) { + $idToken = $token->parseIdToken(); + } + $_SESSION['access_token'] = $token->getAccessToken(); + $_SESSION['refresh_token'] = $token->getRefreshToken(); + + render('token', [ + 'title' => 'トークン更新', + 'token' => $token, + 'idToken' => $idToken + ]); + } + ] +]; + + +try { + session_start(); + date_default_timezone_set('Asia/Tokyo'); + $url = parse_url($_SERVER['REQUEST_URI']); + if (!$url) { + throw new \Exception('Invalid uri'); + } + $path = $url['path']; + $method = $_SERVER['REQUEST_METHOD']; + if (!isset($routes[$path][$method])) { + throw new \Exception('Invalid page'); + } + $routes[$path][$method](); +} catch (\Throwable $e) { + http_response_code(500); + header('Content-Type: text/html'); + render('error', [ + 'trace' => $e->getTraceAsString(), + 'message' => $e->getMessage(), + ]); +} + +/** + * @param array $settings + * @return Client + */ +function getClient(array $settings): Client +{ + $metadata = new ClientMetadata( + $settings['client']['client_id'], + $settings['client']['client_secret'], + $settings['client']['redirect_uri'] + ); + $provider = new Provider($settings['provider']); + return new Client($metadata, $provider); +} + +function h(string $v): string +{ + return htmlspecialchars($v, ENT_QUOTES | ENT_HTML5, "UTF-8"); +} + +function render(string $fileName, array $data = []) +{ + extract($data, EXTR_REFS); + ob_start(); + include __DIR__ . + DIRECTORY_SEPARATOR . + '..' . + DIRECTORY_SEPARATOR . + 'views' . + DIRECTORY_SEPARATOR . + basename($fileName) . + '.php'; + echo ob_get_clean(); +} diff --git a/examples/public/style.css b/examples/public/style.css new file mode 100644 index 0000000..29b221d --- /dev/null +++ b/examples/public/style.css @@ -0,0 +1,224 @@ +* { + margin: 0; + padding: 0; +} + +body { + color: #555; + font-size: 14px; + line-height: 1.8em; +} + +h1, h2, h3, h4, h5, h6 { + font-size: 1em; +} + +.layout { + display: grid; + min-height: 100%; + grid-template-areas: + "header" + "nav" + "." + "main" + "footer"; + grid-template-columns: 1fr; + grid-template-rows: auto auto 20px 1fr 80px; +} + +.l-header { + grid-area: header; + border-top: 1px solid #34b792; + border-bottom: 1px solid #d9d9d9; + background-color: #fff; + padding: 10px; +} + +.l-nav { + grid-area: nav; + background-color: #eee; + padding: 8px 0; +} + +.l-main { + grid-area: main; +} + +.l-footer { + grid-area: footer; +} + +.c-container { + width: 90%; + max-width: 1280px; + margin: 0 auto; +} + +.c-title { + font-size: 18px; + color: #34b792; + text-decoration: none; +} + +.c-box { + border: 1px solid #333; +} + +.c-box__title { + font-size: 16px; + color: #fff; + background: #555; + line-height: 2.8em; + text-indent: 10px; + display: block; +} + +.c-box__sub-title { + font-size: 14px; + color: #444; + line-height: 2.1em; + text-indent: 10px; + border-left: 5px solid #d9d9d9; + margin-bottom: 10px; + display: block; +} + +.c-box__desc { + padding: 20px; +} + +.c-order-list { + margin-left: 1.3em; +} + +.c-order-list__item { + margin-bottom: 1em; +} + +.c-order-list__item:last-child { + margin-bottom: 0; +} + +.c-table { + width: 100%; + border-collapse: collapse; +} + +.c-table__row { + border: solid 1px #fff; +} + +.c-table__row:last-child { + border-bottom: none; +} + +.c-table__title { + position: relative; + text-align: left; + width: 30%; + max-width: 140px; + background-color: #34b792; + color: #fff; + padding: 10px; +} + +.c-table__desc { + text-align: left; + width: 70%; + background-color: #fafafa; + padding: 10px; + word-break: break-word; +} + +.c-breadcrumb { + font-size: 12px; +} + +.c-breadcrumb__item { + display: inline; + list-style: none; + font-weight: 300; +} + +.c-breadcrumb__item:after { + color: #aaa; + font-weight: 600; + content: '>'; + padding: 0 4px 0 8px; +} + +.c-breadcrumb__item:last-child:after { + content: ''; +} + +.c-breadcrumb__item__link { + color: #0074e0; + text-decoration: none; +} + +.c-breadcrumb__item__link:hover { + text-decoration: underline; +} + +.c-badge { + font-size: 13px; + font-weight: 700; + line-height: 1em; + outline: 1px solid #d9d9d9; + padding: 2px; + margin: 0 2px; + background: #eee; + border-radius: 3px; + display: inline-block; +} + +.c-button { + padding: 8px 10px; + text-decoration: none; + text-align: center; + vertical-align: middle; + font-weight: 700; + color: #fff; + background-color: #34b792; + border: 1px solid #34b792; + border-radius: 2px; + cursor: pointer; +} + +.c-button--small { + font-size: 12px; + width: 120px; +} + +.c-button--medium { + font-size: 14px; + width: 240px; +} + +.u-text-left { + text-align: left; +} + +.u-text-center { + text-align: center; +} + +.u-text-right { + text-align: right; +} + +.u-inline-block { + display: inline-block; +} + +.u-block { + display: block; +} + +.u-mt { + margin-top: 20px; +} + +.u-mb { + margin-bottom: 20px; +} \ No newline at end of file diff --git a/examples/settings.php b/examples/settings.php new file mode 100644 index 0000000..c1b3910 --- /dev/null +++ b/examples/settings.php @@ -0,0 +1,16 @@ + [ + 'client_id' => '', + 'client_secret' => '', + 'redirect_uri' => '' + ], + 'provider' => [ + 'issuer' => '', + 'authorization_endpoint' => '', + 'token_endpoint' => '', + 'userinfo_endpoint' => '', + 'jwks_endpoint' => '', + ] +]; diff --git a/examples/views/error.php b/examples/views/error.php new file mode 100644 index 0000000..318de97 --- /dev/null +++ b/examples/views/error.php @@ -0,0 +1,40 @@ + + + + + + + エラーが発生しました | GameWith アカウント連携サンプル + + + + +
+
+
+

エラーが発生しました

+
+ + + + + + + + + + +
メッセージ
トレース
+
+
+ +
+
+ + \ No newline at end of file diff --git a/examples/views/index.php b/examples/views/index.php new file mode 100644 index 0000000..406527e --- /dev/null +++ b/examples/views/index.php @@ -0,0 +1,43 @@ + + + + + + + GameWith アカウント連携サンプル + + + + + +
+
+
+

処理の流れ

+
+
    +
  1. examples/settings.phpの各種項目を入力してください。
  2. +
  3. 当画面から「GameWith ログイン」ボタンをクリックしてください。
  4. +
  5. GameWithログイン画面およびGameWith同意画面が表示されます。GameWith同意画面が表示されたら許可するまたはキャンセルを選択してください。
  6. +
  7. 同意画面で許可するまたはキャンセルを選択すると、指定されたリダイレクト先に遷移します。成功時はクエリーパラメーターにcode (認可コード)が付与されます。エラー時はクエリパラメーターにerror, error_descriptionが付与されます。クエリパラメーターに付与されているstateはCSRF対策として、同一セッションかリダイレクト先で確認するために利用します。
  8. +
  9. GameWithから付与された認可コードを指定してトークンリクエストをします。トークンリクエストが成功するとトークン情報が返却されます。
  10. +
+
+
+ +
+
+ + \ No newline at end of file diff --git a/examples/views/token.php b/examples/views/token.php new file mode 100644 index 0000000..37f8aee --- /dev/null +++ b/examples/views/token.php @@ -0,0 +1,100 @@ + + + + + + + <?= $title ?> | GameWith アカウント連携サンプル + + + + + +
+
+
+

アクション

+ +
+
+

トークン情報

+
+

トークンレスポンス

+ + + + + + + + + + + + + + + + + + + + + + + +
access_tokengetAccessToken()) ?>
refresh_tokengetRefreshToken()) ?>
id_tokengetIdToken()) ?? 'なし' ?>
scopegetScope()) ?>
expires_ingetExpiresIn()) ?> (sec)
+
+
+ +
+

id_token の解析

+
+

ヘッダー情報

+ + + $val): ?> + + + + + + +
+

ペイロード情報

+ + + $val): ?> + + + + + + +
+ + + () + +
+
+
+ +
+
+ + \ No newline at end of file diff --git a/examples/views/userinfo.php b/examples/views/userinfo.php new file mode 100644 index 0000000..710867c --- /dev/null +++ b/examples/views/userinfo.php @@ -0,0 +1,45 @@ + + + + + + + ユーザー情報取得 | GameWith アカウント連携サンプル + + + + + +
+
+
+

ユーザー情報

+
+

レスポンス

+ + + $val): ?> + + + + + + +
+
+
+
+
+ + \ No newline at end of file diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..c308dcf --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,4 @@ +parameters: + level: 8 + paths: + - src diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..fe01445 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,18 @@ + + + + + ./tests + + + + + ./src + + + \ No newline at end of file diff --git a/src/Client.php b/src/Client.php new file mode 100644 index 0000000..ab2deac --- /dev/null +++ b/src/Client.php @@ -0,0 +1,242 @@ +metadata = $metadata; + $this->provider = $provider; + $this->client = new \GuzzleHttp\Client([ + 'timeout' => 5, + 'verify' => true, + 'headers' => [ + 'User-Agent' => sprintf('GameWithOidcSDK/%s PHP/%s', self::VERSION, PHP_VERSION), + ], + ]); + } + + /** + * HttpClient をセットする + * + * @param ClientInterface $client + * @return void + */ + public function setHttpClient(ClientInterface $client) + { + $this->client = $client; + } + + /** + * 認証リクエストを行う + * + * @param AuthenticationRequestProperty $property + * @return RedirectResponse + */ + public function sendAuthenticationRequest( + AuthenticationRequestProperty $property + ): RedirectResponse { + $property->setMetadata($this->metadata); + $property->valid(); + $url = sprintf( + "%s?%s", + $this->provider->getAuthorizationEndpoint(), + http_build_query($property->params(), '', '&', PHP_QUERY_RFC3986) + ); + return new RedirectResponse($url, 302); + } + + /** + * 認可コードを受け取る + * + * @param string|null $state + * @return string + * @throws InvalidResponseException + */ + public function receiveAuthenticationRequest($state = null): string + { + $filterInput = new FilterInput([ + 'code' => FILTER_DEFAULT, + 'state' => FILTER_DEFAULT, + 'error' => FILTER_DEFAULT, + 'error_description' => FILTER_DEFAULT, + ]); + + $params = $filterInput->values(INPUT_GET); + + if (empty($params)) { + throw new InvalidResponseException('empty query strings'); + } + + if (isset($params['error'])) { + $message = sprintf( + 'error: %s, error_description: %s', + $params['error'], + $params['error_description'] ?? '' + ); + throw new InvalidResponseException($message); + } + + if (isset($params['state']) && $params['state'] !== $state) { + throw new InvalidResponseException('invalid state'); + } + + if (!isset($params['code'])) { + throw new InvalidResponseException('code is undefined'); + } + + return $params['code']; + } + + /** + * 認可コードを元にトークン発行リクエストをする + * + * @param ExchangeProperty $property + * @return Token + * @throws Exception\JsonErrorException + * @throws Exception\OidcClientException + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function exchange(ExchangeProperty $property): Token + { + $property->setMetadata($this->metadata); + $property->valid(); + + $jwks = $this->getJwks(); + + $endpoint = $this->provider->getTokenEndpoint(); + $response = $this->client->request('POST', $endpoint, [ + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + 'Authorization' => $this->metadata->getAuthorization(), + ], + 'form_params' => $property->params(), + ]); + + $body = Json::decode($response->getBody()->getContents(), true); + return new Token($body, $jwks, $this->provider, $this->metadata); + } + + /** + * トークンの更新をする + * + * @param RefreshProperty $property + * @return Token + * @throws Exception\JsonErrorException + * @throws InvalidResponseException + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function refresh(RefreshProperty $property): Token + { + $property->setMetadata($this->metadata); + $property->valid(); + + $jwks = $this->getJwks(); + + $endpoint = $this->provider->getTokenEndpoint(); + $response = $this->client->request('POST', $endpoint, [ + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'form_params' => $property->params(), + ]); + + $body = Json::decode($response->getBody()->getContents(), true); + return new Token($body, $jwks, $this->provider, $this->metadata); + } + + /** + * Jwks を取得する + * + * @return array> + * @throws Exception\JsonErrorException + * @throws Exception\InvalidResponseException + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function getJwks(): array + { + $endpoint = $this->provider->getJwksEndpoint(); + $response = $this->client->request('GET', $endpoint); + $body = Json::decode($response->getBody()->getContents(), true); + if (!isset($body['keys']) || !is_array($body['keys'])) { + throw new InvalidResponseException('invalid jwks'); + } + return $body; + } + + /** + * ユーザー情報取得リクエストをする + * + * @param string $accessToken + * @return array + * @throws Exception\JsonErrorException + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function userInfoRequest(string $accessToken): array + { + $endpoint = $this->provider->getUserInfoEndpoint(); + $response = $this->request('GET', $endpoint, $accessToken); + return Json::decode($response->getBody()->getContents(), true); + } + + /** + * 認可情報を組み込んでリクエストをする + * + * @param string $method + * @param string $url + * @param string $accessToken + * @param array $options + * @return \Psr\Http\Message\ResponseInterface + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function request( + string $method, + string $url, + string $accessToken, + array $options = [] + ): \Psr\Http\Message\ResponseInterface { + $options = array_merge_recursive([ + 'headers' => [ + 'Authorization' => 'Bearer ' . $accessToken, + ], + ], $options); + return $this->client->request($method, $url, $options); + } +} diff --git a/src/ClientMetadata.php b/src/ClientMetadata.php new file mode 100644 index 0000000..3615840 --- /dev/null +++ b/src/ClientMetadata.php @@ -0,0 +1,91 @@ +clientId = $clientId; + $this->clientSecret = $clientSecret; + $this->redirectUri = $redirectUri; + } + + /** + * client_id の取得 + * + * @return string + */ + public function getClientId(): string + { + return $this->clientId; + } + + /** + * client_secret の取得 + * + * @return string + */ + public function getClientSecret(): string + { + return $this->clientSecret; + } + + /** + * redirect_uri の取得 + * + * @return string + */ + public function getRedirectUri(): string + { + return $this->redirectUri; + } + + /** + * トークンリクエスト時 Authorization Header にセットする値を取得する + * + * @return string + */ + public function getAuthorization(): string + { + return sprintf("%s %s", TokenType::BASIC, $this->clientSecret); + } +} diff --git a/src/Constant/GrantType.php b/src/Constant/GrantType.php new file mode 100644 index 0000000..60a29f9 --- /dev/null +++ b/src/Constant/GrantType.php @@ -0,0 +1,15 @@ + + */ + private $jwk; + + /** + * Jwk constructor. + * + * @param array $jwk + */ + public function __construct(array $jwk) + { + if (empty($jwk)) { + throw new \UnexpectedValueException('jwk is empty'); + } + if (!isset($jwk['kty']) || !in_array($jwk['kty'], self::SUPPORT_KEY_TYPES, true)) { + throw new \UnexpectedValueException('unsupported key type'); + } + $this->valid($jwk); + $this->jwk = $jwk; + } + + /** + * Jwk の KeyId を取得する + * + * @return string + */ + public function getKeyId(): string + { + return (string) $this->jwk['kid']; + } + + /** + * Jwk を 公開鍵に変換する + * + * @return PublicKey + * @throws Base64Exception + */ + public function toPublicKey(): PublicKey + { + $e = base64_decode($this->jwk['e']); + if (!$e) { + throw new Base64Exception('base64 decode of jwk[e] failed'); + } + $n = Base64Url::decode($this->jwk['n']); + if (!$n) { + throw new Base64Exception('base64 decode of jwk[n] failed'); + } + return PublicKeyLoader::loadPublicKey([ + 'e' => new BigInteger($e, 256), + 'n' => new BigInteger($n, 256) + ]); + } + + /** + * 公開鍵の検証 + * + * @param array $jwk + * @return void + */ + private function valid(array $jwk) + { + if ($jwk['kty'] === 'RSA') { + $this->validRSA($jwk); + } else { + throw new \UnexpectedValueException('unsupported key type'); + } + } + + /** + * RSA 公開鍵の検証 + * + * @param array $jwk + * @return void + */ + private function validRSA(array $jwk) + { + if (!isset($jwk['e'], $jwk['n'])) { + throw new \UnexpectedValueException('invalid jwk format'); + } + } +} diff --git a/src/Jwx/JwkSet.php b/src/Jwx/JwkSet.php new file mode 100644 index 0000000..0a11371 --- /dev/null +++ b/src/Jwx/JwkSet.php @@ -0,0 +1,43 @@ + $jwks + * @param array $header + * @return Jwk + * @throws \UnexpectedValueException + * @throws NotFoundException + */ + public static function find(array $jwks, array $header): Jwk + { + if (empty($jwks)) { + throw new \UnexpectedValueException('jwks is empty'); + } + + if (!isset($jwks['keys'])) { + throw new \UnexpectedValueException('jwks is invalid format'); + } + + if (empty($header) || !isset($header['kid']) || $header['kid'] === '') { + throw new \UnexpectedValueException('header.kid is required'); + } + $keyId = $header['kid']; + foreach ($jwks['keys'] as $jwk) { + if ($jwk['kid'] === $keyId) { + return new Jwk($jwk); + } + } + throw new NotFoundException('not found jwk'); + } +} diff --git a/src/Jwx/Jws.php b/src/Jwx/Jws.php new file mode 100644 index 0000000..c9d53f5 --- /dev/null +++ b/src/Jwx/Jws.php @@ -0,0 +1,195 @@ + 'verifyPkcs1', + ]; + + /** + * 発行日時許容秒数 + * + * @var int + */ + private $allowableIatSec = 0; + + /** + * 発行日時許容秒数の指定 + * + * @param int $sec + * @return void + */ + public function setAllowableIatSec(int $sec) + { + $this->allowableIatSec = $sec; + } + + /** + * JWT トークンの検証 + * + * @param string $token + * @param PublicKey $publicKey + * @return bool + * @throws JsonErrorException + * @throws InvalidTokenException + * @throws Base64Exception + */ + public function verify(string $token, PublicKey $publicKey): bool + { + $parts = explode('.', $token); + if (count($parts) !== 3) { + throw new \UnexpectedValueException('invalid token format'); + } + + return $this->verifyBySplitToken($parts, $publicKey); + } + + /** + * JWT トークンの検証(引数がトークン分割済) + * + * @param array $parts + * @param PublicKey $publicKey + * @return bool + * @throws InvalidTokenException + * @throws JsonErrorException + * @throws Base64Exception + */ + public function verifyBySplitToken(array $parts, PublicKey $publicKey): bool + { + $decodeBase64Header = Base64Url::decode($parts[0]); + if (!$decodeBase64Header) { + throw new Base64Exception('base64 url decode of header failed'); + } + + $header = Json::decode($decodeBase64Header, true); + + if (!isset($header['alg'])) { + throw new \UnexpectedValueException('undefined alg'); + } + + if (!in_array($header['alg'], self::SUPPORTED_ALGORITHMS, true)) { + throw new \UnexpectedValueException('unsupported alg'); + } + + $decodeBase64Payload = base64_decode($parts[1]); + if (!$decodeBase64Payload) { + throw new Base64Exception('base64 decode of payload failed'); + } + + $payload = Json::decode(base64_decode($parts[1]), true); + + $now = time(); + if (!$this->verifyExpiresAt($payload, $now)) { + throw new InvalidTokenException('token is expired'); + } + + if (!$this->verifyIssuedAt($payload, $now)) { + throw new InvalidTokenException('token used before issued'); + } + + if (!$this->verifyNotBefore($payload, $now)) { + throw new InvalidTokenException('token is not valid yet'); + } + + $signature = Base64Url::decode($parts[2]); + if (!$signature) { + throw new Base64Exception('base64 decode of payload failed'); + } + + $message = $parts[0] . '.' . $parts[1]; + $verifyMethod = self::VERIFY_METHODS[$header['alg']]; + return $this->$verifyMethod($message, $signature, $publicKey); + } + + /** + * JWT トークンの失効日時を検証 + * + * @param array $payload JWT Payload + * @param int $now Unix timestamp + * @return bool + */ + private function verifyExpiresAt(array $payload, int $now): bool + { + if (!isset($payload['exp'])) { + return true; + } + if (!is_int($payload['exp'])) { + return false; + } + return $now <= $payload['exp']; + } + + /** + * JWT トークンの発行日時を検証 + * + * @param array $payload JWT Payload + * @param int $now Unix timestamp + * @return bool + */ + private function verifyIssuedAt(array $payload, int $now): bool + { + if (!isset($payload['iat'])) { + return true; + } + if (!is_int($payload['iat'])) { + return false; + } + return $now >= ($payload['iat'] - $this->allowableIatSec); + } + + /** + * JWT トークンの有効開始日時を検証 + * + * @param array $payload JWT Payload + * @param int $now Unix timestamp + * @return bool + */ + private function verifyNotBefore(array $payload, int $now): bool + { + if (!isset($payload['nbf'])) { + return true; + } + if (!is_int($payload['nbf'])) { + return false; + } + return $now >= $payload['nbf']; + } + + /** + * RSA(PKCS1) のトークン検証 + * + * @param string $message + * @param string $signature + * @param PublicKey $publicKey + * @return bool + * @throws InvalidTokenException + */ + private function verifyPkcs1( + string $message, + string $signature, + PublicKey $publicKey + ): bool { + if (!$publicKey instanceof RSA\PublicKey) { + throw new \UnexpectedValueException('public key must be RSA'); + } + return $publicKey + ->withPadding(RSA::SIGNATURE_PKCS1 | RSA::ENCRYPTION_PKCS1) + ->verify($message, $signature); + } +} diff --git a/src/Property/AuthenticationRequestProperty.php b/src/Property/AuthenticationRequestProperty.php new file mode 100644 index 0000000..d6e266b --- /dev/null +++ b/src/Property/AuthenticationRequestProperty.php @@ -0,0 +1,253 @@ +responseType = $responseType; + $this->scopeBuilder = ScopeBuilder::make(); + } + + /** + * プロパティの設定が正しいか検証する + * + * @return void + */ + public function valid() + { + if (empty($this->responseType)) { + throw new \UnexpectedValueException('response_type is empty'); + } + + if ( + !in_array($this->responseType, self::SUPPORT_RESPONSE_TYPES, true) + ) { + throw new \UnexpectedValueException( + 'response_type is not supported' + ); + } + + if (is_null($this->metadata)) { + throw new \UnexpectedValueException('metadata is empty'); + } + + if ($this->scopeBuilder->isEmpty()) { + throw new \UnexpectedValueException('scope is empty'); + } + } + + /** + * 認証リクエストで利用するパラメータを取得する + * + * @return array + */ + public function params(): array + { + if (is_null($this->metadata)) { + throw new \UnexpectedValueException('metadata is empty'); + } + + $params = [ + 'redirect_uri' => $this->metadata->getRedirectUri(), + 'response_type' => $this->responseType, + 'scope' => $this->getScope(), + 'client_id' => $this->metadata->getClientId(), + ]; + + if (!is_null($this->state)) { + $params['state'] = $this->state; + } + if (!is_null($this->maxAge)) { + $params['max_age'] = $this->maxAge; + } + if (!is_null($this->nonce)) { + $params['nonce'] = $this->nonce; + } + if (!is_null($this->codeChallenge)) { + $params['code_challenge'] = $this->codeChallenge; + } + + return $params; + } + + /** + * クライアントのメタ情報を設定する + * + * @param ClientMetadata $metadata + * @return AuthenticationRequestProperty + */ + public function setMetadata(ClientMetadata $metadata): self + { + $this->metadata = $metadata; + return $this; + } + + /** + * レスポンスタイプを取得する + * + * @return string + */ + public function getResponseType(): string + { + return $this->responseType; + } + + /** + * ステートを取得する + * + * @return string|null + */ + public function getState() + { + return $this->state; + } + + /** + * CSRF対策のために利用し、ランダム文字列を設定する + * + * @param string $state + * @return AuthenticationRequestProperty + */ + public function setState(string $state): self + { + $this->state = $state; + return $this; + } + + /** + * ノンスを取得する + * + * @return string|null + */ + public function getNonce() + { + return $this->nonce; + } + + /** + * リプレイアタック対策のために利用し、ランダム文字列を設定する + * + * @param string $nonce + * @return AuthenticationRequestProperty + */ + public function setNonce(string $nonce): self + { + $this->nonce = $nonce; + return $this; + } + + /** + * codeChallenge を取得する + * + * @return null|string + */ + public function getCodeChallenge() + { + return $this->codeChallenge; + } + + /** + * PKCE 対策のために利用し、codeVerifier を元に計算した値を設定する + * + * @param string $codeChallenge + * @return AuthenticationRequestProperty + */ + public function setCodeChallenge(string $codeChallenge): self + { + $this->codeChallenge = $codeChallenge; + return $this; + } + + /** + * トークン有効秒数を取得する + * + * @return int|null + */ + public function getMaxAge() + { + return $this->maxAge; + } + + /** + * トークン有効秒数を設定する + * + * @param int $maxAge + * @return AuthenticationRequestProperty + */ + public function setMaxAge(int $maxAge): self + { + $this->maxAge = $maxAge; + return $this; + } + + /** + * スコープを追加する + * + * @param string ...$scope + * @return AuthenticationRequestProperty + */ + public function addScope(string ...$scope): self + { + $this->scopeBuilder->add(...$scope); + return $this; + } + + /** + * スコープを取得する + * + * @return string + */ + public function getScope(): string + { + return $this->scopeBuilder->build(); + } +} diff --git a/src/Property/ExchangeProperty.php b/src/Property/ExchangeProperty.php new file mode 100644 index 0000000..20072f8 --- /dev/null +++ b/src/Property/ExchangeProperty.php @@ -0,0 +1,185 @@ +code = $code; + $this->grantType = $grantType; + $this->scopeBuilder = ScopeBuilder::make(); + } + + /** + * プロパティの設定が正しいか検証する + * + * @return void + */ + public function valid() + { + if (empty($this->code)) { + throw new \UnexpectedValueException('code is empty'); + } + + if (empty($this->grantType)) { + throw new \UnexpectedValueException('grant_type is required'); + } + + if (!in_array($this->grantType, self::SUPPORT_GRANT_TYPES, true)) { + throw new \UnexpectedValueException('grant_type is not supported'); + } + + if (is_null($this->metadata)) { + throw new \UnexpectedValueException('metadata is required'); + } + + if ($this->scopeBuilder->isEmpty()) { + throw new \UnexpectedValueException('scope is required'); + } + } + + /** + * トークンリクエストで利用するパラメータを取得する + * + * @return array + */ + public function params(): array + { + if (is_null($this->metadata)) { + throw new \UnexpectedValueException('metadata is required'); + } + + $params = [ + 'code' => $this->code, + 'client_id' => $this->metadata->getClientId(), + 'redirect_uri' => $this->metadata->getRedirectUri(), + 'grant_type' => $this->grantType, + 'scope' => $this->getScope(), + ]; + + if (!is_null($this->codeVerifier)) { + $params['code_verifier'] = $this->codeVerifier; + } + + return $params; + } + + /** + * クライアントのメタ情報を設定する + * + * @param ClientMetadata $metadata + * @return ExchangeProperty + */ + public function setMetadata(ClientMetadata $metadata): self + { + $this->metadata = $metadata; + return $this; + } + + /** + * グラントタイプを取得する + * + * @return string + */ + public function getGrantType(): string + { + return $this->grantType; + } + + /** + * 認可コードを取得する + * + * @return string + */ + public function getCode(): string + { + return $this->code; + } + + /** + * 認証リクエスト時に設定した codeChallenge に一致する codeVerifier を設定する + * + * @param string $codeVerifier + * @return ExchangeProperty + */ + public function setCodeVerifier(string $codeVerifier): self + { + $this->codeVerifier = $codeVerifier; + return $this; + } + + /** + * codeVerifier を取得する + * + * @return null|string + */ + public function getCodeVerifier() + { + return $this->codeVerifier; + } + + /** + * スコープを追加する + * + * @param string ...$scope + * @return ExchangeProperty + */ + public function addScope(string ...$scope): self + { + $this->scopeBuilder->add(...$scope); + return $this; + } + + /** + * スコープを取得する + * + * @return string + */ + public function getScope(): string + { + return $this->scopeBuilder->build(); + } +} diff --git a/src/Property/RefreshProperty.php b/src/Property/RefreshProperty.php new file mode 100644 index 0000000..3f605cd --- /dev/null +++ b/src/Property/RefreshProperty.php @@ -0,0 +1,155 @@ +refreshToken = $refreshToken; + $this->scopeBuilder = ScopeBuilder::make(); + } + + /** + * プロパティの設定が正しいか検証する + * + * @return void + */ + public function valid() + { + if (empty($this->refreshToken)) { + throw new \UnexpectedValueException('refresh_token is required'); + } + + if (is_null($this->metadata)) { + throw new \UnexpectedValueException('metadata is required'); + } + + if ($this->scopeBuilder->isEmpty()) { + throw new \UnexpectedValueException('scope is required'); + } + } + + /** + * リフレッシュリクエストで利用するパラメータを取得する + * + * @return array + */ + public function params(): array + { + if (is_null($this->metadata)) { + throw new \UnexpectedValueException('metadata is required'); + } + + $params = [ + 'client_id' => $this->metadata->getClientId(), + 'client_secret' => $this->metadata->getClientSecret(), + 'grant_type' => GrantType::REFRESH_TOKEN, + 'refresh_token' => $this->refreshToken, + 'scope' => $this->getScope(), + ]; + + if (!empty($this->nonce)) { + $params['nonce'] = $this->nonce; + } + + return $params; + } + + /** + * クライアントのメタ情報を設定する + * + * @param ClientMetadata $metadata + * @return RefreshProperty + */ + public function setMetadata(ClientMetadata $metadata): self + { + $this->metadata = $metadata; + return $this; + } + + /** + * リフレッシュトークンを取得する + * + * @return string + */ + public function getRefreshToken(): string + { + return $this->refreshToken; + } + + /** + * スコープを取得する + * + * @return string + */ + public function getScope(): string + { + return $this->scopeBuilder->build(); + } + + /** + * スコープを追加する + * + * @param string ...$scope + * @return RefreshProperty + */ + public function addScope(string ...$scope): self + { + $this->scopeBuilder->add(...$scope); + return $this; + } + + /** + * ノンスを取得する + * + * @return string|null + */ + public function getNonce() + { + return $this->nonce; + } + + /** + * リプレイアタック対策のために利用し、ランダム文字列を設定する + * + * @param string $nonce + * @return RefreshProperty + */ + public function setNonce($nonce): self + { + $this->nonce = $nonce; + return $this; + } +} diff --git a/src/Provider.php b/src/Provider.php new file mode 100644 index 0000000..4ba0af0 --- /dev/null +++ b/src/Provider.php @@ -0,0 +1,102 @@ + + */ + private $requiredAttributes = [ + 'issuer', + 'authorization_endpoint', + 'token_endpoint', + 'userinfo_endpoint', + 'jwks_endpoint', + ]; + + /** + * @var array + */ + private $attributes = []; + + /** + * Provider constructor. + * + * @param array $attributes + */ + public function __construct(array $attributes) + { + if (empty($attributes)) { + throw new \UnexpectedValueException('Attributes are required'); + } + foreach ($attributes as $key => $value) { + if (!in_array($key, $this->requiredAttributes, true)) { + continue; + } + if (empty($value)) { + throw new \UnexpectedValueException('Attribute ' . $key . ' is empty'); + } + $this->attributes[$key] = $value; + } + $includedKeys = array_keys($this->attributes); + $missingKeys = array_diff($this->requiredAttributes, $includedKeys); + if (!empty($missingKeys)) { + throw new \UnexpectedValueException('Missing attributes: ' . implode(', ', $missingKeys)); + } + } + + /** + * 発行者の取得をする + * + * @return string + */ + public function getIssuer(): string + { + return $this->attributes['issuer']; + } + + /** + * 認証(認可)リクエストのエンドポイントを取得する + * + * @return string + */ + public function getAuthorizationEndpoint(): string + { + return $this->attributes['authorization_endpoint']; + } + + /** + * トークンリクエストのエンドポイントを取得する + * + * @return string + */ + public function getTokenEndpoint(): string + { + return $this->attributes['token_endpoint']; + } + + /** + * ユーザー情報リクエストのエンドポイントを取得する + * + * @return string + */ + public function getUserinfoEndpoint(): string + { + return $this->attributes['userinfo_endpoint']; + } + + /** + * Jwks エンドポイントを取得する + * + * @return string + */ + public function getJwksEndpoint(): string + { + return $this->attributes['jwks_endpoint']; + } +} diff --git a/src/Token.php b/src/Token.php new file mode 100644 index 0000000..4473371 --- /dev/null +++ b/src/Token.php @@ -0,0 +1,277 @@ + + */ + private $jwks; + /** + * @var Provider + */ + private $provider; + /** + * @var ClientMetadata + */ + private $metadata; + + /** + * Token constructor. + * + * @param array{access_token: string, refresh_token: string, expires_in: int, scope: string, id_token: string|null} $body + * @param array $jwks + * @param Provider $provider + * @param ClientMetadata $metadata + */ + public function __construct( + array $body, + array $jwks, + Provider $provider, + ClientMetadata $metadata + ) { + $this->accessToken = $body['access_token']; + $this->refreshToken = $body['refresh_token']; + $this->expiresIn = $body['expires_in']; + $this->scope = $body['scope']; + $this->idToken = $body['id_token'] ?? null; + $this->jwks = $jwks; + $this->provider = $provider; + $this->metadata = $metadata; + } + + /** + * IDToken の検証 + * + * @param string|null $nonce + * @param int $allowableIatSec + * @return array{header: array, payload: array} + * @throws Exception\JsonErrorException + * @throws Exception\NotFoundException + * @throws InvalidTokenException + * @throws Base64Exception + */ + public function parseIdToken(string $nonce = null, $allowableIatSec = 5): array + { + if (is_null($this->idToken)) { + throw new \UnexpectedValueException('empty id_token'); + } + + $parts = explode('.', $this->idToken); + + if (count($parts) !== 3) { + throw new \UnexpectedValueException('invalid id_token format'); + } + + $decodeBase64Header = Base64Url::decode($parts[0]); + if (!$decodeBase64Header) { + throw new Base64Exception('base64 decode of header failed'); + } + + $header = Json::decode($decodeBase64Header, true); + $jwk = JwkSet::find($this->jwks, $header); + $publicKey = $jwk->toPublicKey(); + + $jws = new Jws(); + $jws->setAllowableIatSec($allowableIatSec); + if (!$jws->verifyBySplitToken($parts, $publicKey)) { + throw new InvalidTokenException('failed to verify id_token'); + } + + $payload = Json::decode(base64_decode($parts[1]), true); + + // 発行者の検証 + if (!$this->verifyIssuer($payload)) { + throw new InvalidTokenException('invalid issuer'); + } + + // 利用者の検証 + if (!$this->verifyAudience($payload)) { + throw new InvalidTokenException('invalid audience'); + } + + // アクセストークンのハッシュ値を検証 + if (!$this->verifyAtHash($payload)) { + throw new InvalidTokenException('invalid at_hash'); + } + + // ノンスの検証 + if (!$this->verifyNonce($payload, $nonce)) { + throw new InvalidTokenException('invalid nonce'); + } + + // 認証時刻の検証 + if (!$this->verifyAuthTime($payload)) { + throw new InvalidTokenException('invalid auth_time'); + } + + return [ + 'header' => $header, + 'payload' => $payload, + ]; + } + + /** + * Audience の検証 + * + * @param array $payload + * @return bool + */ + private function verifyAudience(array $payload): bool + { + if (!isset($payload['aud'])) { + return false; + } + return $payload['aud'] === $this->metadata->getClientId(); + } + + /** + * ユーザー認証時刻の検証 + * + * @param array $payload + * @return bool + */ + private function verifyAuthTime(array $payload): bool + { + if (!isset($payload['auth_time'])) { + return true; + } + $authTime = $payload['auth_time']; + $dt = new \DateTime(); + $dt->setTimestamp($authTime); + $dt->modify(sprintf('+%d second', $this->getExpiresIn())); + $now = new \DateTime(); + return $dt > $now; + } + + /** + * 発行者の検証 + * + * @param array $payload + * @return bool + */ + private function verifyIssuer(array $payload): bool + { + if (!isset($payload['iss'])) { + return false; + } + return $payload['iss'] === $this->provider->getIssuer(); + } + + /** + * アクセストークンのハッシュ値を検証 + * + * @param array $payload + * @return bool + */ + private function verifyAtHash(array $payload): bool + { + if (!isset($payload['at_hash'])) { + return false; + } + // RS256 固定なので SHA256 でハッシュ + $atHash = hash('sha256', $this->accessToken, true); + // 左半分の 128bits を base64url エンコード (128 bits = 16 bytes) + $atHash = Base64Url::encode(substr($atHash, 0, 16)); + return $atHash === $payload['at_hash']; + } + + /** + * リプレイアタックを防止するための検証 + * + * @param array $payload + * @param string|null $nonce + * @return bool + */ + private function verifyNonce(array $payload, string $nonce = null): bool + { + if (!isset($payload['nonce'])) { + return true; + } + return $nonce === $payload['nonce']; + } + + /** + * アクセストークンの取得 + * + * @return string + */ + public function getAccessToken(): string + { + return $this->accessToken; + } + + /** + * リフレッシュトークンの取得 + * + * @return string + */ + public function getRefreshToken(): string + { + return $this->refreshToken; + } + + /** + * トークン有効期限(秒)を取得 + * + * @return int + */ + public function getExpiresIn(): int + { + return $this->expiresIn; + } + + /** + * スコープの取得 + * + * @return string + */ + public function getScope(): string + { + return $this->scope; + } + + /** + * IDToken の取得 + * + * スコープに openid が含まれていない場合は、含まれない + * + * @return string|null + */ + public function getIdToken() + { + return $this->idToken; + } +} diff --git a/src/Util/Base64Url.php b/src/Util/Base64Url.php new file mode 100644 index 0000000..e974d12 --- /dev/null +++ b/src/Util/Base64Url.php @@ -0,0 +1,39 @@ + + */ + private $definition; + + /** + * FilterInput constructor. + * + * @param array $definition + */ + public function __construct(array $definition) + { + $this->definition = $definition; + } + + /** + * Filter values + * + * @param int $type + * @param bool $addEmpty + * @return mixed + */ + public function values(int $type, bool $addEmpty = true) + { + return filter_input_array($type, $this->definition, $addEmpty); + } +} diff --git a/src/Util/Json.php b/src/Util/Json.php new file mode 100644 index 0000000..c9af0b5 --- /dev/null +++ b/src/Util/Json.php @@ -0,0 +1,49 @@ +url = $url; + $this->status = $status; + } + + /** + * リダイレクト対象の URL を取得する + * + * @return string + */ + public function getUrl(): string + { + return $this->url; + } + + /** + * リダイレクト時の HTTP Status を取得する + * + * @return int + */ + public function getHttpStatus(): int + { + return $this->status; + } + + /** + * リダイレクト実行 + * + * @return void + */ + public function redirect() + { + header('Location: ' . $this->url, true, $this->status); + exit(); + } +} diff --git a/src/Util/ScopeBuilder.php b/src/Util/ScopeBuilder.php new file mode 100644 index 0000000..eea7f56 --- /dev/null +++ b/src/Util/ScopeBuilder.php @@ -0,0 +1,101 @@ + + */ + private $scopes = []; + + /** + * ScopeBuilder constructor. + * + * @param string ...$scopes + */ + private function __construct(string ...$scopes) + { + $this->add(...$scopes); + } + + /** + * スコープインスタンスの生成 + * + * @param string ...$scopes + * @return ScopeBuilder + */ + public static function make(string ...$scopes): self + { + return new self(...$scopes); + } + + /** + * 追加したスコープをビルドする + * + * @return string + */ + public function build(): string + { + return implode(' ', $this->scopes); + } + + /** + * スコープの追加 + * + * @param string ...$scopes + * @return ScopeBuilder + */ + public function add(string ...$scopes): self + { + foreach ($scopes as $scope) { + if ($this->exists($scope)) { + continue; + } + if (!$this->validate($scope)) { + throw new \UnexpectedValueException(sprintf('%s is invalid scope', $scope)); + } + $this->scopes[] = $scope; + } + return $this; + } + + /** + * スコープに不正な文字列が存在するか検証する + * + * @param string $scope + * @return bool + */ + public function validate(string $scope): bool + { + if (preg_match('/^[a-zA-Z0-9_\-\.]+$/', $scope)) { + return true; + } + return false; + } + + /** + * 既に同じスコープが存在するかチェックする + * + * @param string $scope + * @return bool + */ + public function exists(string $scope): bool + { + return in_array($scope, $this->scopes, true); + } + + /** + * スコープが空か判定する + * + * @return bool + */ + public function isEmpty(): bool + { + return empty($this->scopes); + } +} diff --git a/tests/ClientMetadataTest.php b/tests/ClientMetadataTest.php new file mode 100644 index 0000000..24b9bcf --- /dev/null +++ b/tests/ClientMetadataTest.php @@ -0,0 +1,68 @@ +expectException(\UnexpectedValueException::class); + new ClientMetadata($clientId, $clientSecret, $redirectUri); + } + + public function providerConstructorFailure() + { + return [ + ['', '', ''], + ['a', '', ''], + ['', 'b', ''], + ['', '', 'http://localhost'], + ['a', 'b', ''], + ['a', '', 'http://localhost'], + ['', 'b', 'http://localhost'], + ]; + } + + public function testGetClientId() + { + $clientId = 'a'; + $clientMetadata = new ClientMetadata($clientId, 'b', 'http://localhost'); + $this->assertEquals($clientId, $clientMetadata->getClientId()); + } + + public function testGetClientSecret() + { + $clientSecret = 'b'; + $clientMetadata = new ClientMetadata('a', $clientSecret, 'http://localhost'); + $this->assertEquals($clientSecret, $clientMetadata->getClientSecret()); + } + + public function testGetRedirectUri() + { + $redirectUri = 'http://localhost'; + $clientMetadata = new ClientMetadata('a', 'b', $redirectUri); + $this->assertEquals($redirectUri, $clientMetadata->getRedirectUri()); + } + + public function testGetAuthorization() + { + $clientId = 'a'; + $clientSecret = 'b'; + $redirectUri = 'http://localhost'; + $clientMetadata = new ClientMetadata($clientId, $clientSecret, $redirectUri); + $this->assertEquals( + sprintf("Basic %s", $clientSecret), + $clientMetadata->getAuthorization() + ); + } +} diff --git a/tests/ClientTest.php b/tests/ClientTest.php new file mode 100644 index 0000000..bf10f77 --- /dev/null +++ b/tests/ClientTest.php @@ -0,0 +1,950 @@ +provider = new Provider([ + 'issuer' => 'http://localhost/issuer', + 'authorization_endpoint' => 'http://localhost/authorization', + 'token_endpoint' => 'http://localhost/token', + 'userinfo_endpoint' => 'http://localhost/userinfo', + 'jwks_endpoint' => 'http://localhost/jwks', + ]); + $this->metadata = new ClientMetadata('abc', 'abc', 'http://localhost'); + } + + public function tear_down() + { + parent::tear_down(); + \Mockery::close(); + } + + /** + * @dataProvider providerSendAuthenticationRequest + * @param AuthenticationRequestProperty $property + * @param $expected + * @param null $exception + */ + public function testSendAuthenticationRequest( + AuthenticationRequestProperty $property, + $expected = null, + $exception = null + ) { + if (!is_null($exception)) { + $this->expectException($exception); + } + $client = $this->setupClient(); + $redirectResponse = $client->sendAuthenticationRequest($property); + if (!is_null($expected)) { + $this->assertEquals($expected, $redirectResponse->getUrl()); + } + } + + /** + * @return array + */ + public function providerSendAuthenticationRequest() + { + return [ + 'empty' => [ + new AuthenticationRequestProperty(), + null, + \UnexpectedValueException::class, + ], + 'empty response_type' => [ + (new AuthenticationRequestProperty(''))->addScope('openid'), + null, + \UnexpectedValueException::class, + ], + 'unsupported response_type' => [ + (new AuthenticationRequestProperty('invalid'))->addScope( + 'openid' + ), + null, + \UnexpectedValueException::class, + ], + 'supported response_type' => [ + (new AuthenticationRequestProperty('code'))->addScope('openid'), + 'http://localhost/authorization?redirect_uri=http%3A%2F%2Flocalhost&response_type=code&scope=openid&client_id=abc', + ], + 'add multiple scope' => [ + (new AuthenticationRequestProperty())->addScope( + 'openid', + 'profile' + ), + 'http://localhost/authorization?redirect_uri=http%3A%2F%2Flocalhost&response_type=code&scope=openid%20profile&client_id=abc', + ], + 'set max_age' => [ + (new AuthenticationRequestProperty()) + ->addScope('openid') + ->setMaxAge(10), + 'http://localhost/authorization?redirect_uri=http%3A%2F%2Flocalhost&response_type=code&scope=openid&client_id=abc&max_age=10', + ], + 'set state' => [ + (new AuthenticationRequestProperty()) + ->addScope('openid') + ->setState('dummy-state'), + 'http://localhost/authorization?redirect_uri=http%3A%2F%2Flocalhost&response_type=code&scope=openid&client_id=abc&state=dummy-state', + ], + 'set nonce' => [ + (new AuthenticationRequestProperty()) + ->addScope('openid') + ->setNonce('dummy-nonce'), + 'http://localhost/authorization?redirect_uri=http%3A%2F%2Flocalhost&response_type=code&scope=openid&client_id=abc&nonce=dummy-nonce', + ], + 'set code_challenge' => [ + (new AuthenticationRequestProperty()) + ->addScope('openid') + ->setNonce('dummy-code-challenge'), + 'http://localhost/authorization?redirect_uri=http%3A%2F%2Flocalhost&response_type=code&scope=openid&client_id=abc&nonce=dummy-code-challenge', + ], + 'all' => [ + (new AuthenticationRequestProperty()) + ->addScope('openid', 'profile') + ->setMaxAge(10) + ->setState('a') + ->setNonce('b') + ->setState('c') + ->setCodeChallenge('d'), + 'http://localhost/authorization?redirect_uri=http%3A%2F%2Flocalhost&response_type=code&scope=openid%20profile&client_id=abc&state=c&max_age=10&nonce=b&code_challenge=d', + ], + ]; + } + + /** + * @dataProvider providerReceiveAuthenticationRequest + * @runInSeparateProcess + * @preserveGlobalState disabled + * @param $params + * @param $settings + * @throws InvalidResponseException + */ + public function testReceiveAuthenticationRequest($params, $settings) + { + $mock = \Mockery::mock('overload:' . FilterInput::class)->makePartial(); + $mock->shouldReceive('values')->andReturn($params); + if (isset($settings['exception'])) { + $this->expectException($settings['exception']); + } + $client = $this->setupClient(); + $code = $client->receiveAuthenticationRequest($settings['state'] ?? null); + if (isset($settings['expected'])) { + $this->assertEquals($settings['expected'], $code); + } + } + + public function providerReceiveAuthenticationRequest() + { + return [ + 'params: false' => [ + false, + [ + 'exception' => InvalidResponseException::class, + ], + ], + 'params: null' => [ + false, + [ + 'exception' => InvalidResponseException::class, + ], + ], + 'params: []' => [ + [], + [ + 'exception' => InvalidResponseException::class, + ], + ], + 'only error' => [ + [ + 'error' => 'invalid request', + ], + [ + 'exception' => InvalidResponseException::class, + ], + ], + 'error and error_description' => [ + [ + 'error' => 'invalid', + 'error_description' => 'invalid scope', + ], + [ + 'exception' => InvalidResponseException::class, + ], + ], + 'mismatch state: null' => [ + [ + 'state' => 'test', + 'code' => 'ok', + ], + [ + 'state' => null, + 'exception' => InvalidResponseException::class, + ], + ], + 'mismatch state: dummy' => [ + [ + 'state' => 'test', + 'code' => 'ok', + ], + [ + 'state' => 'dummy', + 'exception' => InvalidResponseException::class, + ], + ], + 'match state' => [ + [ + 'state' => 'test', + 'code' => 'ok', + ], + [ + 'state' => 'test', + 'expected' => 'ok', + ], + ], + 'included error and code' => [ + [ + 'error' => 'invalid request', + 'code' => 'ok', + ], + [ + 'exception' => InvalidResponseException::class, + ], + ], + 'only code' => [ + [ + 'code' => 'ok', + ], + [ + 'expected' => 'ok', + ], + ], + ]; + } + + /** + * @dataProvider providerExchange + * @param array $responses + * @param ExchangeProperty $property + * @param array $settings + * @throws JsonErrorException + * @throws \GameWith\Oidc\Exception\OidcClientException + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function testExchange( + array $responses, + ExchangeProperty $property, + array $settings + ) { + if (isset($settings['exception'])) { + $this->expectException($settings['exception']); + } + $client = $this->setupClient(); + $client->setHttpClient(new MockHttpClient($responses)); + $token = $client->exchange($property); + if (isset($settings['expected'])) { + $this->assertEquals($settings['expected'], [ + 'access_token' => $token->getAccessToken(), + 'refresh_token' => $token->getRefreshToken(), + 'expires_in' => $token->getExpiresIn(), + 'scope' => $token->getScope(), + 'id_token' => $token->getIdToken(), + ]); + } + } + + /** + * @return array + * @throws JsonErrorException + */ + public function providerExchange() + { + return [ + 'empty property' => [ + [ + new Response( + 200, + ['Content-Type' => 'application/json'], + Loader::load('jwks.json') + ), + new Response( + 200, + ['Content-Type' => 'application/json'], + Json::encode([ + 'access_token' => 'dummy-access-token', + 'refresh_token' => 'dummy-refresh-token', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => 'dummy-access-token', + ]) + ), + ], + new ExchangeProperty(''), + [ + 'exception' => \UnexpectedValueException::class, + ], + ], + 'empty scope property' => [ + [ + new Response( + 200, + ['Content-Type' => 'application/json'], + Loader::load('jwks.json') + ), + new Response( + 200, + ['Content-Type' => 'application/json'], + Json::encode([ + 'access_token' => 'dummy-access-token', + 'refresh_token' => 'dummy-refresh-token', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => 'dummy-access-token', + ]) + ), + ], + new ExchangeProperty('code'), + [ + 'exception' => \UnexpectedValueException::class, + ], + ], + 'invalid grant_type property' => [ + [ + new Response( + 200, + ['Content-Type' => 'application/json'], + Loader::load('jwks.json') + ), + new Response( + 200, + ['Content-Type' => 'application/json'], + Json::encode([ + 'access_token' => 'dummy-access-token', + 'refresh_token' => 'dummy-refresh-token', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => 'dummy-access-token', + ]) + ), + ], + (new ExchangeProperty('code', 'dummy'))->addScope('openid'), + [ + 'exception' => \UnexpectedValueException::class, + ], + ], + 'server error: jwks' => [ + [ + new Response( + 500, + ['Content-Type' => 'application/json'], + 'Internal Server Error' + ), + new Response( + 200, + ['Content-Type' => 'application/json'], + Json::encode([ + 'access_token' => 'dummy-access-token', + 'refresh_token' => 'dummy-refresh-token', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => 'dummy-access-token', + ]) + ), + ], + (new ExchangeProperty('dummy'))->addScope('openid'), + [ + 'exception' => ServerException::class, + ], + ], + 'client error: jwks' => [ + [ + new Response( + 403, + ['Content-Type' => 'application/json'], + 'Forbidden' + ), + new Response( + 200, + ['Content-Type' => 'application/json'], + Json::encode([ + 'access_token' => 'dummy-access-token', + 'refresh_token' => 'dummy-refresh-token', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => 'dummy-access-token', + ]) + ), + ], + (new ExchangeProperty('dummy'))->addScope('openid'), + [ + 'exception' => ClientException::class, + ], + ], + 'server error: token' => [ + [ + new Response( + 200, + ['Content-Type' => 'application/json'], + Loader::load('jwks.json') + ), + new Response( + 500, + ['Content-Type' => 'application/json'], + '{"error":"server_error"}' + ), + ], + (new ExchangeProperty('dummy'))->addScope('openid'), + [ + 'exception' => ServerException::class, + ], + ], + 'client error: token' => [ + [ + new Response( + 200, + ['Content-Type' => 'application/json'], + Loader::load('jwks.json') + ), + new Response( + 400, + ['Content-Type' => 'application/json'], + '{"error":"invalid_request"}' + ), + ], + (new ExchangeProperty('dummy'))->addScope('openid'), + [ + 'exception' => ClientException::class, + ], + ], + 'exclude id_token' => [ + [ + new Response( + 200, + ['Content-Type' => 'application/json'], + Loader::load('jwks.json') + ), + new Response( + 200, + ['Content-Type' => 'application/json'], + Json::encode([ + 'access_token' => 'dummy-access-token', + 'refresh_token' => 'dummy-refresh-token', + 'expires_in' => 600, + 'scope' => 'profile', + ]) + ), + ], + (new ExchangeProperty('code'))->addScope('openid', 'profile'), + [ + 'expected' => [ + 'access_token' => 'dummy-access-token', + 'refresh_token' => 'dummy-refresh-token', + 'expires_in' => 600, + 'scope' => 'profile', + 'id_token' => null, + ], + ], + ], + 'success' => [ + [ + new Response( + 200, + ['Content-Type' => 'application/json'], + Loader::load('jwks.json') + ), + new Response( + 200, + ['Content-Type' => 'application/json'], + Json::encode([ + 'access_token' => 'dummy-access-token', + 'refresh_token' => 'dummy-refresh-token', + 'expires_in' => 600, + 'scope' => 'openid profile', + 'id_token' => 'dummy-access-token', + ]) + ), + ], + (new ExchangeProperty('code'))->addScope('openid', 'profile'), + [ + 'expected' => [ + 'access_token' => 'dummy-access-token', + 'refresh_token' => 'dummy-refresh-token', + 'expires_in' => 600, + 'scope' => 'openid profile', + 'id_token' => 'dummy-access-token', + ], + ], + ], + ]; + } + + /** + * @dataProvider providerRefresh + * @param array $responses + * @param RefreshProperty $property + * @param array $settings + * @throws InvalidResponseException + * @throws JsonErrorException + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function testRefresh( + array $responses, + RefreshProperty $property, + array $settings + ) { + if (isset($settings['exception'])) { + $this->expectException($settings['exception']); + } + $client = $this->setupClient(); + $client->setHttpClient(new MockHttpClient($responses)); + $token = $client->refresh($property); + if (isset($settings['expected'])) { + $this->assertEquals($settings['expected'], [ + 'access_token' => $token->getAccessToken(), + 'refresh_token' => $token->getRefreshToken(), + 'expires_in' => $token->getExpiresIn(), + 'scope' => $token->getScope(), + 'id_token' => $token->getIdToken(), + ]); + } + } + + /** + * @return array + * @throws JsonErrorException + */ + public function providerRefresh() + { + return [ + 'empty property' => [ + [ + new Response( + 200, + ['Content-Type' => 'application/json'], + Loader::load('jwks.json') + ), + new Response( + 200, + ['Content-Type' => 'application/json'], + Json::encode([ + 'access_token' => 'dummy-access-token', + 'refresh_token' => 'dummy-refresh-token', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => 'dummy-access-token', + ]) + ), + ], + new RefreshProperty(''), + [ + 'exception' => \UnexpectedValueException::class, + ], + ], + 'empty scope property' => [ + [ + new Response( + 200, + ['Content-Type' => 'application/json'], + Loader::load('jwks.json') + ), + new Response( + 200, + ['Content-Type' => 'application/json'], + Json::encode([ + 'access_token' => 'dummy-access-token', + 'refresh_token' => 'dummy-refresh-token', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => 'dummy-access-token', + ]) + ), + ], + new RefreshProperty('success-refresh-token'), + [ + 'exception' => \UnexpectedValueException::class, + ], + ], + 'server error: jwks' => [ + [ + new Response( + 500, + ['Content-Type' => 'application/json'], + 'Internal Server Error' + ), + new Response( + 200, + ['Content-Type' => 'application/json'], + Json::encode([ + 'access_token' => 'dummy-access-token', + 'refresh_token' => 'dummy-refresh-token', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => 'dummy-access-token', + ]) + ), + ], + (new RefreshProperty('dummy'))->addScope('openid'), + [ + 'exception' => ServerException::class, + ], + ], + 'client error: jwks' => [ + [ + new Response( + 403, + ['Content-Type' => 'application/json'], + 'Forbidden' + ), + new Response( + 200, + ['Content-Type' => 'application/json'], + Json::encode([ + 'access_token' => 'dummy-access-token', + 'refresh_token' => 'dummy-refresh-token', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => 'dummy-access-token', + ]) + ), + ], + (new RefreshProperty('dummy'))->addScope('openid'), + [ + 'exception' => ClientException::class, + ], + ], + 'server error: token' => [ + [ + new Response( + 200, + ['Content-Type' => 'application/json'], + Loader::load('jwks.json') + ), + new Response( + 500, + ['Content-Type' => 'application/json'], + '{"error":"server_error"}' + ), + ], + (new RefreshProperty('dummy'))->addScope('openid'), + [ + 'exception' => ServerException::class, + ], + ], + 'client error: token' => [ + [ + new Response( + 200, + ['Content-Type' => 'application/json'], + Loader::load('jwks.json') + ), + new Response( + 400, + ['Content-Type' => 'application/json'], + '{"error":"invalid_request"}' + ), + ], + (new RefreshProperty('dummy'))->addScope('openid'), + [ + 'exception' => ClientException::class, + ], + ], + 'exclude id_token' => [ + [ + new Response( + 200, + ['Content-Type' => 'application/json'], + Loader::load('jwks.json') + ), + new Response( + 200, + ['Content-Type' => 'application/json'], + Json::encode([ + 'access_token' => 'dummy-access-token', + 'refresh_token' => 'dummy-refresh-token', + 'expires_in' => 600, + 'scope' => 'profile', + ]) + ), + ], + (new RefreshProperty('success-refresh-token'))->addScope( + 'openid', + 'profile' + ), + [ + 'expected' => [ + 'access_token' => 'dummy-access-token', + 'refresh_token' => 'dummy-refresh-token', + 'expires_in' => 600, + 'scope' => 'profile', + 'id_token' => null, + ], + ], + ], + 'success' => [ + [ + new Response( + 200, + ['Content-Type' => 'application/json'], + Loader::load('jwks.json') + ), + new Response( + 200, + ['Content-Type' => 'application/json'], + Json::encode([ + 'access_token' => 'dummy-access-token', + 'refresh_token' => 'dummy-refresh-token', + 'expires_in' => 600, + 'scope' => 'openid profile', + 'id_token' => 'dummy-access-token', + ]) + ), + ], + (new RefreshProperty('success-refresh-token'))->addScope( + 'openid', + 'profile' + ), + [ + 'expected' => [ + 'access_token' => 'dummy-access-token', + 'refresh_token' => 'dummy-refresh-token', + 'expires_in' => 600, + 'scope' => 'openid profile', + 'id_token' => 'dummy-access-token', + ], + ], + ], + ]; + } + + /** + * @dataProvider providerGetJwks + * @param array $responses + * @param array|null $expected + * @param string|null $exception + * @throws InvalidResponseException + * @throws JsonErrorException + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function testGetJwks( + array $responses, + $expected = null, + $exception = null + ) { + if (!is_null($exception)) { + $this->expectException($exception); + } + $client = $this->setupClient(); + $client->setHttpClient(new MockHttpClient($responses)); + $act = $client->getJwks(); + if (!is_null($expected)) { + $this->assertEquals($expected, $act); + } + } + + public function providerGetJwks() + { + return [ + 'empty body' => [ + [new Response(200, ['Content-Type' => 'text/plain'], '')], + null, + JsonErrorException::class, + ], + 'invalid body' => [ + [ + new Response( + 200, + ['Content-Type' => 'application/json'], + '{"dummy":"ok"}' + ), + ], + null, + InvalidResponseException::class, + ], + 'invalid status: 400' => [ + [ + new Response( + 400, + ['Content-Type' => 'application/json'], + '{"dummy":"ok"}' + ), + ], + null, + ClientException::class, + ], + 'invalid status: 500' => [ + [ + new Response( + 500, + ['Content-Type' => 'application/json'], + '{"dummy":"ok"}' + ), + ], + null, + ServerException::class, + ], + 'valid' => [ + [ + new Response( + 200, + ['Content-Type' => 'application/json'], + Loader::load('jwks.json') + ), + ], + Loader::loadJson('jwks.json'), + ], + ]; + } + + /** + * @dataProvider providerUserInfoRequest + * @param array $responses + * @param string $accessToken + * @param array $settings + * @throws JsonErrorException + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function testUserInfoRequest( + array $responses, + string $accessToken, + array $settings + ) { + if (isset($settings['exception'])) { + $this->expectException($settings['exception']); + } + $client = $this->setupClient(); + $client->setHttpClient(new MockHttpClient($responses)); + $res = $client->userInfoRequest($accessToken); + if (isset($settings['expected'])) { + $this->assertEquals($settings['expected'], $res); + } + } + + /** + * @return array + * @throws JsonErrorException + */ + public function providerUserInfoRequest() + { + return [ + 'unauthorized error' => [ + [ + new Response( + 401, + ['Content-Type' => 'application/json'], + Json::encode(['error' => 'unauthorized_error']) + ) + ], + 'xxx', + [ + 'exception' => ClientException::class + ] + ], + 'server error' => [ + [ + new Response( + 500, + ['Content-Type' => 'application/json'], + Json::encode(['error' => 'server_error']) + ) + ], + 'xxx', + [ + 'exception' => ServerException::class + ] + ], + 'scope: openid' => [ + [ + new Response( + 200, + ['Content-Type' => 'application/json'], + Json::encode(['sub' => '1009332']) + ) + ], + 'xxx', + [ + 'expected' => [ + 'sub' => '1009332' + ] + ] + ], + 'scope: openid profile' => [ + [ + new Response( + 200, + ['Content-Type' => 'application/json'], + Json::encode(['sub' => '1009332', 'name' => 'dummy']) + ) + ], + 'xxx', + [ + 'expected' => [ + 'sub' => '1009332', + 'name' => 'dummy' + ] + ] + ] + ]; + } + + public function testRequest() + { + $client = $this->setupClient(); + $mockClient = new MockHttpClient([ + new Response(200, ['Content-Type' => 'application/json'], '{"status":"ok"}'), + ]); + $client->setHttpClient($mockClient); + $client->request('GET', 'http://localhost', 'xxx', [ + 'headers' => [ + 'X-Test-Header' => 'test', + 'Authorization' => 'ccc', + ] + ]); + $container = $mockClient->getContainer(); + $request = $container[0]['request']; + $headers = $request->getHeaders(); + $this->assertEquals('test', $headers['X-Test-Header'][0]); + $this->assertEquals('Bearer xxx', $headers['Authorization'][0]); + } + + /** + * @return Client + */ + private function setupClient(): Client + { + return new Client($this->metadata, $this->provider); + } +} diff --git a/tests/Fixture/Loader.php b/tests/Fixture/Loader.php new file mode 100644 index 0000000..a78ffd7 --- /dev/null +++ b/tests/Fixture/Loader.php @@ -0,0 +1,24 @@ +mock = new MockHandler($responses); + $history = Middleware::history($this->container); + $this->handlerStack = HandlerStack::create($this->mock); + $this->handlerStack->push($history); + parent::__construct([ + 'handler' => $this->handlerStack + ]); + } + + public function getHandlerStack(): HandlerStack + { + return $this->handlerStack; + } + + public function getContainer(): array + { + return $this->container; + } +} diff --git a/tests/Fixture/assets/jwks.json b/tests/Fixture/assets/jwks.json new file mode 100644 index 0000000..32f9805 --- /dev/null +++ b/tests/Fixture/assets/jwks.json @@ -0,0 +1,12 @@ +{ + "keys": [ + { + "use": "sig", + "kty": "RSA", + "n": "tCF4VWE_kU50urZh1J0EhoB5tawfJO6irwHOAVfXhY3YTXSGHKiWs9W8JEK7XcO5MEl1OpKed38_c73PlHvzMbdvgq_t4QYKxEwOgQKuvkolMxUxngBoI5W6SSQOtBUWIGbHPv5wxSDW_O-UOA0LxjHrX46mqguuRr5RPwtVSbiBaqYuItCIvLDUgAZbjlZBdW2yz3ycoWL_91UF07JMoHEFLU9xWYRZormo6xx3ocf0KHA-A-gU1ImWv0520ZCUdzZoJAJWenajJd7MZCFR-eR8Md6jfL1Pz4k_uFZO6UGdq0UAt0kBY-up_5HN3UezHO0JCGODKaoA_sHBontvWw", + "e": "AQAB", + "kid": "K4+KUu62ufiVoBCZvboGdHIUbmU9D3zH", + "alg": "RS256" + } + ] +} diff --git a/tests/Fixture/assets/test1_rsa.pub b/tests/Fixture/assets/test1_rsa.pub new file mode 100644 index 0000000..1d2009e --- /dev/null +++ b/tests/Fixture/assets/test1_rsa.pub @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtCF4VWE/kU50urZh1J0E +hoB5tawfJO6irwHOAVfXhY3YTXSGHKiWs9W8JEK7XcO5MEl1OpKed38/c73PlHvz +Mbdvgq/t4QYKxEwOgQKuvkolMxUxngBoI5W6SSQOtBUWIGbHPv5wxSDW/O+UOA0L +xjHrX46mqguuRr5RPwtVSbiBaqYuItCIvLDUgAZbjlZBdW2yz3ycoWL/91UF07JM +oHEFLU9xWYRZormo6xx3ocf0KHA+A+gU1ImWv0520ZCUdzZoJAJWenajJd7MZCFR ++eR8Md6jfL1Pz4k/uFZO6UGdq0UAt0kBY+up/5HN3UezHO0JCGODKaoA/sHBontv +WwIDAQAB +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/tests/Fixture/assets/test2_rsa.pub b/tests/Fixture/assets/test2_rsa.pub new file mode 100644 index 0000000..2582f56 --- /dev/null +++ b/tests/Fixture/assets/test2_rsa.pub @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr3i7Hbf0us3Y2HGnLHhU +Y4UsBrX/6RL/JhwJHCkIx2A50X94wAuiPn4JoLDNpQFubfTYgvR0XS9K+aEHe6lj +OKBXDlrwo4TTE8CKqu9IbRLH2YnVELCnKghROmJVrdsP93aAs8jFaiSFpIsctv9Y +prCXxlGTGn6NAvSD+rrRwLIgHvC3S1FbuGji9We119FIoO56wQo0SVniKOiI/8vv +Uh2EoS3fp1puP+rCWBDg1gfK0c7Qm8xnZfmk896NSqsJN+ghCSPI/DyFdg1m+Q3k +SVPxRt1GWJJH3nOw7Jbrqoj5cxym4F0j2D9qTgQeCek7PwywM0WvSPIldjdvzKWk +2QIDAQAB +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/tests/Jwx/JwkSetTest.php b/tests/Jwx/JwkSetTest.php new file mode 100644 index 0000000..23adf50 --- /dev/null +++ b/tests/Jwx/JwkSetTest.php @@ -0,0 +1,201 @@ +expectException($exception); + } + $jwk = JwkSet::find($jwks, $header, $exception); + $this->assertEquals($header['kid'], $jwk->getKeyId()); + } + + public function providerFind() + { + return [ + [[], [], \UnexpectedValueException::class], + [ + [ + 'dummy' => [], + ], + [], + \UnexpectedValueException::class, + ], + [ + [ + 'keys' => [], + ], + [], + \UnexpectedValueException::class, + ], + [ + [ + 'keys' => [], + ], + [ + 'dummy' => '', + ], + \UnexpectedValueException::class, + ], + [ + [ + 'keys' => [], + ], + [ + 'kid' => '', + ], + \UnexpectedValueException::class, + ], + [ + [ + 'keys' => [], + ], + [ + 'kid' => 'dummy', + ], + NotFoundException::class, + ], + [ + [ + 'keys' => [ + [ + 'kid' => 'dummy', + ], + ], + ], + [ + 'kid' => 'dummy2', + ], + NotFoundException::class, + ], + [ + [ + 'keys' => [ + [ + 'kid' => 'dummy', + ], + [ + 'kid' => 'dummy2', + ], + ], + ], + [ + 'kid' => 'dummy2', + ], + \UnexpectedValueException::class, + ], + [ + [ + 'keys' => [ + [ + 'kid' => 'dummy', + 'kty' => 'RSA', + ], + ], + ], + [ + 'kid' => 'dummy', + ], + \UnexpectedValueException::class, + ], + [ + [ + 'keys' => [ + [ + 'kid' => 'dummy', + 'kty' => 'RSA', + 'e' => 'AQAB', + ], + ], + ], + [ + 'kid' => 'dummy', + ], + \UnexpectedValueException::class, + ], + [ + [ + 'keys' => [ + [ + 'kid' => 'dummy', + 'kty' => 'RSA', + 'n' => '...', + ], + ], + ], + [ + 'kid' => 'dummy', + ], + \UnexpectedValueException::class, + ], + [ + [ + 'keys' => [ + [ + 'kid' => 'dummy', + 'kty' => 'RSA', + 'use' => 'sig', + 'e' => 'AQAB', + 'n' => '...', + ], + [ + 'kid' => 'dummy2', + 'kty' => 'EC', + 'use' => 'sig', + 'x' => '...', + 'y' => '...', + 'crv' => 'P-521', + ], + ], + ], + [ + 'kid' => 'dummy2', + ], + \UnexpectedValueException::class, + ], + [ + [ + 'keys' => [ + [ + 'kid' => 'dummy', + 'kty' => 'RSA', + 'use' => 'sig', + 'e' => 'AQAB', + 'n' => '...', + ], + [ + 'kid' => 'dummy2', + 'kty' => 'EC', + 'use' => 'sig', + 'x' => '...', + 'y' => '...', + 'crv' => 'P-521', + ], + ], + ], + [ + 'kid' => 'dummy', + ], + null, + ], + ]; + } +} diff --git a/tests/Jwx/JwkTest.php b/tests/Jwx/JwkTest.php new file mode 100644 index 0000000..8de92ee --- /dev/null +++ b/tests/Jwx/JwkTest.php @@ -0,0 +1,41 @@ +toPublicKey(); + $actual = str_replace(["\r\n", "\r"], "\n", (string) $pubKey); + if ($equal) { + $this->assertEquals($expected, $actual); + } else { + $this->assertNotEquals($expected, $actual); + } + } + + public function providerToPublicKey() + { + return [ + [ + Loader::load('test1_rsa.pub'), + Loader::loadJson('jwks.json'), + true, + ], + [ + Loader::load('test2_rsa.pub'), + Loader::loadJson('jwks.json'), + false, + ], + ]; + } +} diff --git a/tests/Jwx/JwsTest.php b/tests/Jwx/JwsTest.php new file mode 100644 index 0000000..82b022b --- /dev/null +++ b/tests/Jwx/JwsTest.php @@ -0,0 +1,165 @@ +expectException($exception); + } + $jws = new Jws(); + $this->assertEquals($expected, $jws->verify($token, $pubKey)); + } + + public function providerVerify() + { + return [ + 'empty' => [ + false, + '', + Loader::loadPublicKey('test1_rsa.pub'), + \UnexpectedValueException::class, + ], + 'invalid token format' => [ + false, + 'x.x.x', + Loader::loadPublicKey('test1_rsa.pub'), + Base64Exception::class, + ], + 'unsupported alg: HS256' => [ + false, + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6ImhvZ2UiLCJ0ZXN0IjoieWVzIiwiaWF0IjoxNTE2MjM5MDIyfQ.iuCtX6SDCja19f_Y2YnMj8w0-1x1fWDe8coqjmy93CI', + Loader::loadPublicKey('test1_rsa.pub'), + \UnexpectedValueException::class, + ], + 'unsupported alg: HS384' => [ + false, + 'eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6ImhvZ2UiLCJ0ZXN0IjoieWVzIiwiaWF0IjoxNTE2MjM5MDIyfQ.DsTcKnlyXHMpOabfsnTjK_HJr6E4hmrCW8bALviNqiklWv8TAsW6N31m5ImwVZEn', + Loader::loadPublicKey('test1_rsa.pub'), + \UnexpectedValueException::class, + ], + 'unsupported alg: HS512' => [ + false, + 'eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6ImhvZ2UiLCJ0ZXN0IjoieWVzIiwiaWF0IjoxNTE2MjM5MDIyfQ.-a4VPFkHwtuWDInCb3eK8HF-qkY8cI_8eWkPPfH1lxuxqBy8lp3hTZi_-5aWGYvHTuWB_aMSvVikb4oxrfP0fQ', + Loader::loadPublicKey('test1_rsa.pub'), + \UnexpectedValueException::class, + ], + 'unsupported alg: ES256' => [ + false, + 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3QiLCJpYXQiOjI1MzM3MDczMjQwMCwiZXhwIjowfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', + Loader::loadPublicKey('test1_rsa.pub'), + \UnexpectedValueException::class, + ], + 'unsupported alg: ES384' => [ + false, + 'eyJhbGciOiJFUzM4NCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3QiLCJpYXQiOjI1MzM3MDczMjQwMCwiZXhwIjowfQ.W-jEzRfkc6taW_hcsWwxk5E_J9gQsETD-UzIwvZOIo', + Loader::loadPublicKey('test1_rsa.pub'), + \UnexpectedValueException::class, + ], + 'unsupported alg: ES512' => [ + false, + 'eyJhbGciOiJFUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3QiLCJpYXQiOjI1MzM3MDczMjQwMCwiZXhwIjowfQ.XbPkXJ-jhjWgPYQVw5HgKJkPZCgKvkK-VzsSL3_MnB8', + Loader::loadPublicKey('test1_rsa.pub'), + \UnexpectedValueException::class, + ], + 'unsupported alg: PS256' => [ + false, + 'eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3QiLCJpYXQiOjI1MzM3MDczMjQwMCwiZXhwIjowfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', + Loader::loadPublicKey('test1_rsa.pub'), + \UnexpectedValueException::class, + ], + 'unsupported alg: PS384' => [ + false, + 'eyJhbGciOiJQUzM4NCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3QiLCJpYXQiOjI1MzM3MDczMjQwMCwiZXhwIjowfQ.W-jEzRfkc6taW_hcsWwxk5E_J9gQsETD-UzIwvZOIo', + Loader::loadPublicKey('test1_rsa.pub'), + \UnexpectedValueException::class, + ], + 'unsupported alg: PS512' => [ + false, + 'eyJhbGciOiJQUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3QiLCJpYXQiOjI1MzM3MDczMjQwMCwiZXhwIjowfQ.XbPkXJ-jhjWgPYQVw5HgKJkPZCgKvkK-VzsSL3_MnB8', + Loader::loadPublicKey('test1_rsa.pub'), + \UnexpectedValueException::class, + ], + 'unsupported alg: RSA384' => [ + false, + 'eyJhbGciOiJSUzM4NCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3QiLCJpYXQiOjI1MzM3MDczMjQwMCwiZXhwIjowfQ.MJcwed9mtJluF5Ufw2kpbdxk47Mtk6WyIRklcdHOoXv-rHIjX9zaUZYPfQXj6iTTEek9zaQ4FMrliJFNIxKL7xrbvDOUgBXUIPYRcDZILh9bo-_lHDfDYGGSoMDSyUd40HBz71POCLh-mQ2OnRW8a6O51XdKvjko7tNQgMFEoZaF3MOjCFhiqk-OWC34dYuATehyfvZ24gXiDF_kOfXCLVS6zafysattb2PuhtEjnqrDIf-Yq2f-aNB9JsX1AdxGadHazk50FCA_5AINxzbVDO_LW4ELP1UYrVsmPaNYwLslAlwmTFhY7GaV0PYLkrHCFwl4gekf7ju5Ct-J5NXYyA', + Loader::loadPublicKey('test1_rsa.pub'), + \UnexpectedValueException::class, + ], + 'unsupported alg: RSA512' => [ + false, + 'eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3QiLCJpYXQiOjI1MzM3MDczMjQwMCwiZXhwIjowfQ.MJcwed9mtJluF5Ufw2kpbdxk47Mtk6WyIRklcdHOoXv-rHIjX9zaUZYPfQXj6iTTEek9zaQ4FMrliJFNIxKL7xrbvDOUgBXUIPYRcDZILh9bo-_lHDfDYGGSoMDSyUd40HBz71POCLh-mQ2OnRW8a6O51XdKvjko7tNQgMFEoZaF3MOjCFhiqk-OWC34dYuATehyfvZ24gXiDF_kOfXCLVS6zafysattb2PuhtEjnqrDIf-Yq2f-aNB9JsX1AdxGadHazk50FCA_5AINxzbVDO_LW4ELP1UYrVsmPaNYwLslAlwmTFhY7GaV0PYLkrHCFwl4gekf7ju5Ct-J5NXYyA', + Loader::loadPublicKey('test1_rsa.pub'), + \UnexpectedValueException::class, + ], + 'expired' => [ + false, + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3QiLCJpYXQiOjI1MzM3MDczMjQwMCwiZXhwIjowfQ.euPjhLgTWMRCwEDoF53Fth0n_t2aC3psTc3fanSlQNc3gNJt7muckIPmnWmwnaLaHh5NIqR3rtpIVCVTkZ7Vgmq_D1XWzeegwsUxar5lrnO6QhFuBIfAZebP_7mLq44IhuqAgFXj7_2U2g7bzgq0VI3fRXaexopNy5roYwwQiGKBxBrPQJocZuxaxjPMGQ4hfEvL7KnEiVnSey4Fm-I4K2BScU1MRaqYA68s5O_o3h91PMYG76hH_8_V3d_PQWwWeQF9nW10Gazu6NMqQ98PALYRCcw7HNgwhKA_vmvw0V3grPWiWqZ6i38CW_G-qYTZ6bnlsK6CJFDHjbsoR7sHBw', + Loader::loadPublicKey('test1_rsa.pub'), + InvalidTokenException::class, + ], + 'before iat' => [ + false, + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3QiLCJpYXQiOjI1MzM3MDczMjQwMCwiZXhwIjoyNTMzNzA3MzI0MDB9.DQSLqn0y3rWohF_UcywGAGdcxTIx97z0dD6DCCDxLuaCKXW7vX8x6Z-U2m_rPbLYU1nvZlrMgrN8PG4hYNNQl2j3TOs74CTDLyx_np5KugRVeHlNYmW6RaeELc2iUHcbG5pvvZ2C_OxfrpC060EdZ-22rlJYNufHpX5zGtINy-K5q0z8oeZIns-WAsrwJOcZPxcALzPebV-XxbSs362ISvmcYQ5eKy5ZL8Zo8Sy1MvQheOHjob8KrjWvnP6M9m3en-6WK-W5EpL7w3CIFVKMvpyqEGQAhJ62iQQA_Q51S71UivOLAMtq7Oi8MTcNFXsxe9qjyunIsE08BqmyldYkzw', + Loader::loadPublicKey('test1_rsa.pub'), + InvalidTokenException::class, + ], + 'before nbf' => [ + false, + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3QiLCJpYXQiOjAsImV4cCI6MjUzMzcwNzMyNDAwLCJuYmYiOjI1MzM3MDczMjQwMH0.Is0bhk1Vwl8HOmTM5YJtJDCHp5w1mA-vf4T4zSkUuosdtqUsnNQJN3xiRH5UMtNtUW0piHXcIdUOq6zbjhuGHlZVi1AOkrDs3oyPEeKcnmTgCPO4QN2zbglwDNEEEZRphho1aOlZSQz6b3BFCCzzdNIdyVdV6I_z7zGIZ35tQAaA6pIt3gdeWol9-5JaYjV4cJjIa4Ou9cA53GlWFjEJ9X4Z82lYUM1UMDB4yEu-1LVztxXhJhqzKYt3UDDdqATHUQ5DXQ4Glwxi2Ub9gzaOSpywSybf-uVxMrlHPLBqvuNQtdNAR0revkVwSeY0lXDwH_mQ7pz0yHubtckcmw1uHA', + Loader::loadPublicKey('test1_rsa.pub'), + InvalidTokenException::class, + ], + 'invalid signature' => [ + false, + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3QiLCJpYXQiOjAsImV4cCI6MjUzMzcwNzMyNDAwfQ.kWktHwTFkqDecfVwqgY0oYF6xrnkcgj5o_68OHK4YffZIkwzuvu0WWdxaOyTatCli3AU_MqbISD0BoGzXeYtX2cSUGQ9U8gg9voE8KFOwMehJ_Cx4jAqiKUqCm5cKXAiqwP7tK-_1kWuQ8FktTvW4xFQOhI4a-DzcO2XV5JFTcp13BZ-s2Jn4h4BXkJH0A2xk9DOfeWYw_wDj-_u8oEnvfIUDQUZ06RQlv3pBKfQA6ee6Atuwl8grnjVWsw4kzIJ7bu3iRffIkpPYZZRhtnr9UOa3me8sz-LtazAyWUP3SK9zrHDMTzBip6a4Yu1f44ckk4C5nLsZUCZ0hffjprsLw', + Loader::loadPublicKey('test2_rsa.pub'), + null, + ], + 'empty exp' => [ + true, + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3QiLCJpYXQiOjAsIm5iZiI6MH0.RCgRz5XRua5KxosEJ77QKfJMILI0sFyEQGNegEgQFj09PAQe4mRsN6OGl9-sFqdhSuYPxT8m6-3JAOKZlUPyqhyXPLP3rl8VV6OkiAZ-Id0hAcFUKTCrUssTyyeRCPbxtkzCVDLPKRD-KrsrX1HRKjnXgPfHxOZQ2eG2PbFldjWLyJHt_6yS5HJ86fJJjeiOZhbi8GZOlkmn2ZsvPpJHtI_OfWelTCseCbHuIT06-ZcutNa8aM4N-ZqAFn-Jpv3cyWOY_qMmXuaqfrGEXqSZeLmPdMNnz5vJwxLvSpF5PcBXRCqW-so1Y7clprDb93yNXmDVW_852iNiOz0_V7t7mA', + Loader::loadPublicKey('test1_rsa.pub'), + null, + ], + 'empty iat' => [ + true, + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3QiLCJleHAiOjI1MzM3MDczMjQwMCwibmJmIjowfQ.VqcHEa9fRmBD4WsFbZHBbDqull__9u_65IwiQPlbrrjTHgqBk13QDc6ZWPK2zeVZ965KfVKI5dM7_gdPAXeYlTuN--MAaGx3w9tvHIjrdRRYWWz4RBEc3G-HaCFUVsQuVv3Mhv9SG6nYmKJ6QpzAuXST0JlCy6QZzdT6hnA5MhRwL9j3P-g2MaMU8oc_wtZ6KwcmmLt15viB61Q7tDxHxUYBgjvwk620zBYSZZIQ8Cc5z2xLj_0C0A4IhcxaDRAq2_rOpS_aD_xZjlI9KrZyRypBSc__jCf02AZtgyJZ0aA0hpRbiFONCW4g9F56Q2Alu38m8ka_CoeJd5XrlFS_dQ', + Loader::loadPublicKey('test1_rsa.pub'), + null, + ], + 'empty nbf' => [ + true, + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3QiLCJpYXQiOjAsImV4cCI6MjUzMzcwNzMyNDAwfQ.kWktHwTFkqDecfVwqgY0oYF6xrnkcgj5o_68OHK4YffZIkwzuvu0WWdxaOyTatCli3AU_MqbISD0BoGzXeYtX2cSUGQ9U8gg9voE8KFOwMehJ_Cx4jAqiKUqCm5cKXAiqwP7tK-_1kWuQ8FktTvW4xFQOhI4a-DzcO2XV5JFTcp13BZ-s2Jn4h4BXkJH0A2xk9DOfeWYw_wDj-_u8oEnvfIUDQUZ06RQlv3pBKfQA6ee6Atuwl8grnjVWsw4kzIJ7bu3iRffIkpPYZZRhtnr9UOa3me8sz-LtazAyWUP3SK9zrHDMTzBip6a4Yu1f44ckk4C5nLsZUCZ0hffjprsLw', + Loader::loadPublicKey('test1_rsa.pub'), + null, + ], + 'valid' => [ + true, + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3QiLCJpYXQiOjAsImV4cCI6MjUzMzcwNzMyNDAwLCJuYmYiOjB9.EnG5cxaDH4cLRtqm_NNqlolhOZOw_GGFFLS6zmvVzLSTpSKvbilrvOKff3OSnyjpcrYyO7eXPKjSLPWRoUxUh5EAsiNNjfIPx4VlNO4IQGyt4OHBuXjM8OurPC5zUPShk1fgkZe0krcqHywRkDza2ECydMDijMTDxGoDoUItVNDVv5YWvg7FhQYywFKrzD94TrcUF3njAA4KQK4WEHNlwwWaqMNK1NzusED1AgL3evRjRmK94S_s0GHMtYpc1475E_fbM4JzCQj_q0BfXX2XdxT6lFegpa1n8NASQscIjVfIkS__bn9Abeg8GSwCkhVsy_mRaDX23iiG2J47BFpZiw', + Loader::loadPublicKey('test1_rsa.pub'), + null, + ], + ]; + } +} diff --git a/tests/Property/AuthenticationRequestPropertyTest.php b/tests/Property/AuthenticationRequestPropertyTest.php new file mode 100644 index 0000000..4edc73e --- /dev/null +++ b/tests/Property/AuthenticationRequestPropertyTest.php @@ -0,0 +1,260 @@ +setMetadata( + new ClientMetadata('a', 'b', 'http://localhost') + ); + $property->addScope('openid'); + $this->assertNull($property->valid()); + } + + /** + * @dataProvider providerValidFailure + * @param $params + */ + public function testValidFailure($params, $expectionClass) + { + $this->expectException($expectionClass); + $property = new AuthenticationRequestProperty($params['response_type']); + $property->addScope(...$params['scopes']); + if (!empty($params['metadata'])) { + $property->setMetadata($params['metadata']); + } + $property->valid(); + } + + /** + * @return array + */ + public function providerValidFailure() + { + return [ + [ + [ + 'response_type' => '', + 'metadata' => null, + 'scopes' => [], + ], + \UnexpectedValueException::class, + ], + [ + [ + 'response_type' => ResponseType::CODE, + 'metadata' => null, + 'scopes' => [], + ], + \UnexpectedValueException::class, + ], + [ + [ + 'response_type' => ResponseType::CODE, + 'metadata' => new ClientMetadata('a', 'b', 'c'), + 'scopes' => [], + ], + \UnexpectedValueException::class, + ], + [ + [ + 'response_type' => '', + 'metadata' => new ClientMetadata('a', 'b', 'c'), + 'scopes' => [], + ], + \UnexpectedValueException::class, + ], + [ + [ + 'response_type' => '', + 'metadata' => new ClientMetadata('a', 'b', 'c'), + 'scopes' => ['openid'], + ], + \UnexpectedValueException::class, + ], + [ + [ + 'response_type' => ResponseType::CODE, + 'metadata' => null, + 'scopes' => ['openid'], + ], + \UnexpectedValueException::class, + ], + [ + [ + 'response_type' => ResponseType::CODE, + 'metadata' => new ClientMetadata('a', 'b', 'c'), + 'scopes' => ['alert(1)'], + ], + \UnexpectedValueException::class, + ], + ]; + } + + public function testGetResponseType() + { + $property = new AuthenticationRequestProperty(); + $this->assertEquals(ResponseType::CODE, $property->getResponseType()); + $property = new AuthenticationRequestProperty('dummy'); + $this->assertEquals('dummy', $property->getResponseType()); + } + + /** + * @covers AuthenticationRequestProperty::addScope() + */ + public function testGetScope() + { + $property = new AuthenticationRequestProperty(); + $this->assertEquals('', $property->getScope()); + $property->addScope('openid')->addScope('openid'); + $this->assertEquals('openid', $property->getScope()); + $property->addScope('profile'); + $this->assertEquals('openid profile', $property->getScope()); + } + + public function testGetState() + { + $property = new AuthenticationRequestProperty(); + $this->assertNull($property->getState()); + $expected = Random::str(); + $property->setState($expected); + $this->assertEquals($expected, $property->getState()); + } + + public function testGetMaxAge() + { + $property = new AuthenticationRequestProperty(); + $this->assertNull($property->getMaxAge()); + $expected = 100; + $property->setMaxAge($expected); + $this->assertEquals($expected, $property->getMaxAge()); + } + + public function testGetNonce() + { + $property = new AuthenticationRequestProperty(); + $this->assertNull($property->getNonce()); + $expected = Random::str(); + $property->setNonce($expected); + $this->assertEquals($expected, $property->getNonce()); + } + + public function testGetCodeChallenge() + { + $property = new AuthenticationRequestProperty(); + $this->assertNull($property->getCodeChallenge()); + $expected = Pkce::createCodeChallenge(Pkce::generateCodeVerifier()); + $property->setCodeChallenge($expected); + $this->assertEquals($expected, $property->getCodeChallenge()); + } + + public function testParams() + { + $property = new AuthenticationRequestProperty(); + $property->setMetadata( + new ClientMetadata('a', 'b', 'http://localhost') + ); + $property->addScope('openid'); + $this->assertEquals( + [ + 'response_type' => ResponseType::CODE, + 'redirect_uri' => 'http://localhost', + 'client_id' => 'a', + 'scope' => 'openid', + ], + $property->params() + ); + $property = new AuthenticationRequestProperty(); + $property->setMetadata( + new ClientMetadata('a', 'b', 'http://localhost') + ); + $property->addScope('openid'); + $property->setState(Random::str()); + $this->assertEquals( + [ + 'response_type' => ResponseType::CODE, + 'redirect_uri' => 'http://localhost', + 'client_id' => 'a', + 'scope' => 'openid', + 'state' => $property->getState(), + ], + $property->params() + ); + $property = new AuthenticationRequestProperty(); + $property->setMetadata( + new ClientMetadata('a', 'b', 'http://localhost') + ); + $property->addScope('openid'); + $property->setState(Random::str()); + $property->setMaxAge(100); + $this->assertEquals( + [ + 'response_type' => ResponseType::CODE, + 'redirect_uri' => 'http://localhost', + 'client_id' => 'a', + 'scope' => 'openid', + 'state' => $property->getState(), + 'max_age' => 100, + ], + $property->params() + ); + $property = new AuthenticationRequestProperty(); + $property->setMetadata( + new ClientMetadata('a', 'b', 'http://localhost') + ); + $property->addScope('openid'); + $property->setState(Random::str()); + $property->setMaxAge(100); + $property->setNonce(Random::str()); + $this->assertEquals( + [ + 'response_type' => ResponseType::CODE, + 'redirect_uri' => 'http://localhost', + 'client_id' => 'a', + 'scope' => 'openid', + 'state' => $property->getState(), + 'max_age' => 100, + 'nonce' => $property->getNonce(), + ], + $property->params() + ); + $property = new AuthenticationRequestProperty(); + $property->setMetadata( + new ClientMetadata('a', 'b', 'http://localhost') + ); + $property->addScope('openid'); + $property->setState(Random::str()); + $property->setMaxAge(100); + $property->setNonce(Random::str()); + $property->setCodeChallenge( + Pkce::createCodeChallenge(Pkce::generateCodeVerifier()) + ); + $this->assertEquals( + [ + 'response_type' => ResponseType::CODE, + 'redirect_uri' => 'http://localhost', + 'client_id' => 'a', + 'scope' => 'openid', + 'state' => $property->getState(), + 'max_age' => $property->getMaxAge(), + 'nonce' => $property->getNonce(), + 'code_challenge' => $property->getCodeChallenge(), + ], + $property->params() + ); + } +} diff --git a/tests/Property/ExchangePropertyTest.php b/tests/Property/ExchangePropertyTest.php new file mode 100644 index 0000000..fd5b65d --- /dev/null +++ b/tests/Property/ExchangePropertyTest.php @@ -0,0 +1,372 @@ +assertEquals( + GrantType::AUTHORIZATION_CODE, + $property->getGrantType() + ); + $property = new ExchangeProperty('code', 'dummy'); + $this->assertEquals('dummy', $property->getGrantType()); + } + + public function testGetCode() + { + $code = 'code'; + $property = new ExchangeProperty($code); + $this->assertEquals($code, $property->getCode()); + } + + public function testGetCodeVerifier() + { + $property = new ExchangeProperty('code'); + $this->assertNull($property->getCodeVerifier()); + $property->setCodeVerifier('dummy'); + $this->assertEquals('dummy', $property->getCodeVerifier()); + } + + public function testGetScope() + { + $property = new ExchangeProperty('code'); + $this->assertEquals('', $property->getScope()); + $property->addScope('openid')->addScope('openid'); + $this->assertEquals('openid', $property->getScope()); + $property->addScope('profile'); + $this->assertEquals('openid profile', $property->getScope()); + } + + /** + * @dataProvider providerValid + * @param $params + * @param $exception + */ + public function testValid($params, $exception) + { + if (!is_null($exception)) { + $this->expectException($exception); + } + $property = new ExchangeProperty( + $params['code'], + $params['grant_type'] + ); + if (isset($params['scope'])) { + $property->addScope(...$params['scope']); + } + if (isset($params['metadata'])) { + $property->setMetadata($params['metadata']); + } + if (isset($params['code_verifier'])) { + $property->setCodeVerifier($params['code_verifier']); + } + $this->assertNull($property->valid()); + } + + public function providerValid() + { + return [ + [ + [ + 'code' => '', + 'grant_type' => '', + ], + \UnexpectedValueException::class, + ], + [ + [ + 'code' => 'code', + 'grant_type' => '', + ], + \UnexpectedValueException::class, + ], + [ + [ + 'code' => 'code', + 'grant_type' => GrantType::AUTHORIZATION_CODE, + ], + \UnexpectedValueException::class, + ], + [ + [ + 'code' => 'code', + 'grant_type' => GrantType::AUTHORIZATION_CODE, + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + ], + \UnexpectedValueException::class, + ], + [ + [ + 'code' => 'code', + 'grant_type' => GrantType::AUTHORIZATION_CODE, + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + 'code_verifier' => '', + ], + \UnexpectedValueException::class, + ], + [ + [ + 'code' => 'code', + 'grant_type' => GrantType::AUTHORIZATION_CODE, + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + 'code_verifier' => 'dummy', + ], + \UnexpectedValueException::class, + ], + [ + [ + 'code' => 'code', + 'grant_type' => GrantType::AUTHORIZATION_CODE, + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + 'code_verifier' => 'dummy', + 'scope' => [], + ], + \UnexpectedValueException::class, + ], + [ + [ + 'code' => 'code', + 'grant_type' => GrantType::AUTHORIZATION_CODE, + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + 'code_verifier' => 'dummy', + 'scope' => ['alert(1)'], + ], + \UnexpectedValueException::class, + ], + [ + [ + 'code' => 'code', + 'grant_type' => GrantType::AUTHORIZATION_CODE, + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + 'code_verifier' => 'dummy', + 'scope' => ['openid', 'alert(1)'], + ], + \UnexpectedValueException::class, + ], + [ + [ + 'code' => 'code', + 'grant_type' => GrantType::AUTHORIZATION_CODE, + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + 'scope' => ['openid'], + ], + null, + ], + [ + [ + 'code' => 'code', + 'grant_type' => GrantType::AUTHORIZATION_CODE, + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + 'code_verifier' => 'dummy', + 'scope' => ['openid'], + ], + null, + ], + [ + [ + 'code' => 'code', + 'grant_type' => GrantType::AUTHORIZATION_CODE, + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + 'code_verifier' => 'dummy', + 'scope' => ['openid', 'profile'], + ], + null, + ], + ]; + } + + /** + * @dataProvider providerParams + * @param $params + * @param $expected + * @param $exception + */ + public function testParams($params, $expected, $exception) + { + if (!is_null($exception)) { + $this->expectException($exception); + } + $property = new ExchangeProperty( + $params['code'], + $params['grant_type'] + ); + if (isset($params['scope'])) { + $property->addScope(...$params['scope']); + } + if (isset($params['metadata'])) { + $property->setMetadata($params['metadata']); + } + if (isset($params['code_verifier'])) { + $property->setCodeVerifier($params['code_verifier']); + } + $this->assertEquals($expected, $property->params()); + } + + public function providerParams() + { + return [ + [ + [ + 'code' => '', + 'grant_type' => '', + ], + [], + \UnexpectedValueException::class, + ], + [ + [ + 'code' => 'code', + 'grant_type' => '', + ], + [], + \UnexpectedValueException::class, + ], + [ + [ + 'code' => 'code', + 'grant_type' => GrantType::AUTHORIZATION_CODE, + ], + [], + \UnexpectedValueException::class, + ], + [ + [ + 'code' => 'code', + 'grant_type' => GrantType::AUTHORIZATION_CODE, + 'code_verifier' => 'dummy', + ], + [], + \UnexpectedValueException::class, + ], + [ + [ + 'code' => 'code', + 'grant_type' => GrantType::AUTHORIZATION_CODE, + 'scope' => ['openid'], + ], + [], + \UnexpectedValueException::class, + ], + [ + [ + 'code' => 'code', + 'grant_type' => GrantType::AUTHORIZATION_CODE, + 'code_verifier' => 'dummy', + 'scope' => ['openid'], + ], + [], + \UnexpectedValueException::class, + ], + [ + [ + 'code' => 'code', + 'grant_type' => GrantType::AUTHORIZATION_CODE, + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + 'code_verifier' => 'dummy', + 'scope' => ['openid'], + ], + [ + 'code' => 'code', + 'grant_type' => GrantType::AUTHORIZATION_CODE, + 'scope' => 'openid', + 'code_verifier' => 'dummy', + 'client_id' => 'a', + 'redirect_uri' => 'http://localhost', + ], + null, + ], + [ + [ + 'code' => 'code', + 'grant_type' => GrantType::AUTHORIZATION_CODE, + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + 'code_verifier' => 'dummy', + 'scope' => ['openid', 'profile'], + ], + [ + 'code' => 'code', + 'grant_type' => GrantType::AUTHORIZATION_CODE, + 'scope' => 'openid profile', + 'code_verifier' => 'dummy', + 'client_id' => 'a', + 'redirect_uri' => 'http://localhost', + ], + null, + ], + [ + [ + 'code' => 'code', + 'grant_type' => GrantType::AUTHORIZATION_CODE, + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + 'code_verifier' => 'dummy', + 'scope' => ['openid', 'openid', 'profile'], + ], + [ + 'code' => 'code', + 'grant_type' => GrantType::AUTHORIZATION_CODE, + 'scope' => 'openid profile', + 'code_verifier' => 'dummy', + 'client_id' => 'a', + 'redirect_uri' => 'http://localhost', + ], + null, + ], + ]; + } +} diff --git a/tests/Property/RefreshPropertyTest.php b/tests/Property/RefreshPropertyTest.php new file mode 100644 index 0000000..b20f6d8 --- /dev/null +++ b/tests/Property/RefreshPropertyTest.php @@ -0,0 +1,420 @@ +assertEquals('a', $property->getRefreshToken()); + } + + public function testGetScope() + { + $property = new RefreshProperty(''); + $property->addScope('openid')->addScope('openid'); + $this->assertEquals('openid', $property->getScope()); + $property->addScope('profile'); + $this->assertEquals('openid profile', $property->getScope()); + } + + /** + * @dataProvider providerValid + * @param $params + * @param $exception + */ + public function testValid($params, $exception) + { + if (!is_null($exception)) { + $this->expectException($exception); + } + $property = new RefreshProperty($params['refresh_token']); + if (isset($params['metadata'])) { + $property->setMetadata($params['metadata']); + } + if (isset($params['scope'])) { + $property->addScope(...$params['scope']); + } + $this->assertNull($property->valid()); + } + + /** + * @return array + */ + public function providerValid() + { + return [ + [ + [ + 'refresh_token' => '', + ], + \UnexpectedValueException::class, + ], + [ + [ + 'refresh_token' => 'a', + ], + \UnexpectedValueException::class, + ], + [ + [ + 'refresh_token' => '', + 'scope' => ['openid'], + ], + \UnexpectedValueException::class, + ], + [ + [ + 'refresh_token' => 'a', + 'scope' => ['alert(1)'], + ], + \UnexpectedValueException::class, + ], + [ + [ + 'refresh_token' => 'a', + 'scope' => ['openid'], + ], + \UnexpectedValueException::class, + ], + [ + [ + 'refresh_token' => 'a', + 'scope' => ['openid', 'alert(1)'], + ], + \UnexpectedValueException::class, + ], + [ + [ + 'refresh_token' => '', + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + ], + \UnexpectedValueException::class, + ], + [ + [ + 'refresh_token' => 'a', + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + ], + \UnexpectedValueException::class, + ], + [ + [ + 'refresh_token' => '', + 'scope' => ['openid'], + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + ], + \UnexpectedValueException::class, + ], + [ + [ + 'refresh_token' => 'a', + 'scope' => ['alert(1)'], + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + ], + \UnexpectedValueException::class, + ], + [ + [ + 'refresh_token' => 'a', + 'scope' => ['openid '], + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + ], + \UnexpectedValueException::class, + ], + [ + [ + 'refresh_token' => 'a', + 'scope' => ['openid'], + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + ], + null, + ], + [ + [ + 'refresh_token' => 'a', + 'scope' => ['openid', 'openid'], + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + ], + null, + ], + [ + [ + 'refresh_token' => 'a', + 'scope' => ['openid', 'profile'], + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + ], + null, + ], + ]; + } + + /** + * @dataProvider providerParams + * @param $params + * @param $expected + * @param $err + */ + public function testParams($params, $expected, $err = []) + { + if (isset($err['exception'])) { + $this->expectException($err['exception']); + } + if (isset($err['error'])) { + $this->expectException('Error'); + } + $property = new RefreshProperty($params['refresh_token']); + if (isset($params['scope'])) { + $property->addScope(...$params['scope']); + } + if (isset($params['metadata'])) { + $property->setMetadata($params['metadata']); + } + $this->assertEquals($expected, $property->params()); + } + + /** + * @return array + */ + public function providerParams() + { + return [ + [ + [ + 'refresh_token' => '', + ], + [], + [ + 'exception' => \UnexpectedValueException::class, + ], + ], + [ + [ + 'refresh_token' => 'a', + ], + [], + [ + 'exception' => \UnexpectedValueException::class, + ], + ], + [ + [ + 'refresh_token' => '', + 'scope' => ['openid'], + ], + [], + [ + 'exception' => \UnexpectedValueException::class, + ], + ], + [ + [ + 'refresh_token' => 'a', + 'scope' => ['?redirect_url=openid'], + ], + [], + [ + 'exception' => \UnexpectedValueException::class, + ], + ], + [ + [ + 'refresh_token' => 'a', + 'scope' => ['openid'], + ], + [], + [ + 'exception' => \UnexpectedValueException::class, + ], + ], + [ + [ + 'refresh_token' => 'a', + 'scope' => ['openid', 'alert(1)'], + ], + [], + [ + 'exception' => \UnexpectedValueException::class, + ], + ], + [ + [ + 'refresh_token' => 'a', + 'scope' => ['alert(1)'], + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + ], + [], + [ + 'exception' => \UnexpectedValueException::class, + ], + ], + [ + [ + 'refresh_token' => 'a', + 'scope' => ['openid alert(1)'], + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + ], + [], + [ + 'exception' => \UnexpectedValueException::class, + ], + ], + [ + [ + 'refresh_token' => '', + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + ], + [ + 'client_id' => 'a', + 'client_secret' => 'b', + 'grant_type' => GrantType::REFRESH_TOKEN, + 'refresh_token' => '', + 'scope' => '', + ], + ], + [ + [ + 'refresh_token' => 'a', + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + ], + [ + 'client_id' => 'a', + 'client_secret' => 'b', + 'grant_type' => GrantType::REFRESH_TOKEN, + 'refresh_token' => 'a', + 'scope' => '', + ], + ], + [ + [ + 'refresh_token' => '', + 'scope' => ['openid'], + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + ], + [ + 'client_id' => 'a', + 'client_secret' => 'b', + 'grant_type' => GrantType::REFRESH_TOKEN, + 'refresh_token' => '', + 'scope' => 'openid', + ], + ], + [ + [ + 'refresh_token' => 'a', + 'scope' => ['openid'], + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + ], + [ + 'client_id' => 'a', + 'client_secret' => 'b', + 'grant_type' => GrantType::REFRESH_TOKEN, + 'refresh_token' => 'a', + 'scope' => 'openid', + ], + ], + [ + [ + 'refresh_token' => 'a', + 'scope' => ['openid', 'openid'], + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + ], + [ + 'client_id' => 'a', + 'client_secret' => 'b', + 'grant_type' => GrantType::REFRESH_TOKEN, + 'refresh_token' => 'a', + 'scope' => 'openid', + ], + ], + [ + [ + 'refresh_token' => 'a', + 'scope' => ['openid', 'profile'], + 'metadata' => new ClientMetadata( + 'a', + 'b', + 'http://localhost' + ), + ], + [ + 'client_id' => 'a', + 'client_secret' => 'b', + 'grant_type' => GrantType::REFRESH_TOKEN, + 'refresh_token' => 'a', + 'scope' => 'openid profile', + ], + ], + ]; + } +} diff --git a/tests/ProviderTest.php b/tests/ProviderTest.php new file mode 100644 index 0000000..d51ecdb --- /dev/null +++ b/tests/ProviderTest.php @@ -0,0 +1,134 @@ +expectException(\UnexpectedValueException::class); + new Provider($attributes); + } + + public function providerConstructorFailure() + { + return [ + [ + [] + ], + [ + [ + 'issuer' => '', + 'authorization_endpoint' => '', + 'token_endpoint' => '', + 'jwks_endpoint' => '', + 'userinfo_endpoint' => '', + ] + ], + [ + [ + 'issuer' => 'http://localhost', + ] + ], + [ + [ + 'issuer' => 'http://localhost', + 'authorization_endpoint' => 'http://localhost/authorize', + ] + ], + [ + [ + 'issuer' => 'http://localhost', + 'authorization_endpoint' => 'http://localhost/authorize', + 'token_endpoint' => 'http://localhost/token', + ] + ], + [ + [ + 'issuer' => 'http://localhost', + 'authorization_endpoint' => 'http://localhost/authorize', + 'token_endpoint' => 'http://localhost/token', + 'userinfo_endpoint' => 'http://localhost/userinfo', + ] + ], + [ + [ + 'issuer' => 'http://localhost', + 'authorization_endpoint' => 'http://localhost/authorize', + 'token_endpoint' => 'http://localhost/token', + 'jwks_endpoint' => 'http://localhost/.well-known/jwks.json', + ] + ] + ]; + } + + public function testGetIssuer() + { + $provider = new Provider([ + 'issuer' => 'http://localhost', + 'authorization_endpoint' => 'http://localhost/authorize', + 'token_endpoint' => 'http://localhost/token', + 'userinfo_endpoint' => 'http://localhost/userinfo', + 'jwks_endpoint' => 'http://localhost/.well-known/jwks.json', + ]); + $this->assertEquals('http://localhost', $provider->getIssuer()); + } + + public function testGetAuthorizationEndpoint() + { + $provider = new Provider([ + 'issuer' => 'http://localhost', + 'authorization_endpoint' => 'http://localhost/authorize', + 'token_endpoint' => 'http://localhost/token', + 'userinfo_endpoint' => 'http://localhost/userinfo', + 'jwks_endpoint' => 'http://localhost/.well-known/jwks.json', + ]); + $this->assertEquals('http://localhost/authorize', $provider->getAuthorizationEndpoint()); + } + + public function testGetTokenEndpoint() + { + $provider = new Provider([ + 'issuer' => 'http://localhost', + 'authorization_endpoint' => 'http://localhost/authorize', + 'token_endpoint' => 'http://localhost/token', + 'userinfo_endpoint' => 'http://localhost/userinfo', + 'jwks_endpoint' => 'http://localhost/.well-known/jwks.json', + ]); + $this->assertEquals('http://localhost/token', $provider->getTokenEndpoint()); + } + + public function testGetUserInfoEndpoint() + { + $provider = new Provider([ + 'issuer' => 'http://localhost', + 'authorization_endpoint' => 'http://localhost/authorize', + 'token_endpoint' => 'http://localhost/token', + 'userinfo_endpoint' => 'http://localhost/userinfo', + 'jwks_endpoint' => 'http://localhost/.well-known/jwks.json', + ]); + $this->assertEquals('http://localhost/userinfo', $provider->getUserInfoEndpoint()); + } + + public function testGetJwksEndpoint() + { + $provider = new Provider([ + 'issuer' => 'http://localhost', + 'authorization_endpoint' => 'http://localhost/authorize', + 'token_endpoint' => 'http://localhost/token', + 'userinfo_endpoint' => 'http://localhost/userinfo', + 'jwks_endpoint' => 'http://localhost/.well-known/jwks.json', + ]); + $this->assertEquals('http://localhost/.well-known/jwks.json', $provider->getJwksEndpoint()); + } +} diff --git a/tests/TokenTest.php b/tests/TokenTest.php new file mode 100644 index 0000000..bc725c5 --- /dev/null +++ b/tests/TokenTest.php @@ -0,0 +1,378 @@ +provider = new Provider([ + 'issuer' => 'http://localhost/issuer', + 'authorization_endpoint' => 'http://localhost/authorization', + 'token_endpoint' => 'http://localhost/token', + 'userinfo_endpoint' => 'http://localhost/userinfo', + 'jwks_endpoint' => 'http://localhost/jwks', + ]); + $this->metadata = new ClientMetadata('abc', 'abc', 'http://localhost'); + } + + public function testGetAccessToken() + { + $token = $this->setupToken([ + 'access_token' => 'access', + 'refresh_token' => 'refresh', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => 'idt', + ]); + $this->assertEquals('access', $token->getAccessToken()); + } + + public function testGetRefreshToken() + { + $token = $this->setupToken([ + 'access_token' => 'access', + 'refresh_token' => 'refresh', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => 'idt', + ]); + $this->assertEquals('refresh', $token->getRefreshToken()); + } + + public function testGetScope() + { + $token = $this->setupToken([ + 'access_token' => 'access', + 'refresh_token' => 'refresh', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => 'idt', + ]); + $this->assertEquals('openid', $token->getScope()); + } + + public function testGetExpiresIn() + { + $token = $this->setupToken([ + 'access_token' => 'access', + 'refresh_token' => 'refresh', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => 'idt', + ]); + $this->assertEquals(600, $token->getExpiresIn()); + } + + public function testGetIdToken() + { + $token = $this->setupToken([ + 'access_token' => 'access', + 'refresh_token' => 'refresh', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => 'idt', + ]); + $this->assertEquals('idt', $token->getIdToken()); + $token = $this->setupToken([ + 'access_token' => 'access', + 'refresh_token' => 'refresh', + 'expires_in' => 600, + 'scope' => 'openid', + ]); + $this->assertNull($token->getIdToken()); + } + + /** + * @dataProvider providerParseIdToken + * @param array $body + * @param array $jwks + * @param $exception + * @param null $nonce + * @param null $expected + * @throws InvalidTokenException + * @throws NotFoundException + * @throws \GameWith\Oidc\Exception\JsonErrorException + * @throws \GameWith\Oidc\Exception\Base64Exception + */ + public function testParseIdToken( + array $body, + array $jwks, + $exception, + $nonce = null, + $expected = null + ) { + if (!is_null($exception)) { + $this->expectException($exception); + } + $token = $this->setupToken($body, $jwks); + $act = $token->parseIdToken($nonce); + if (!is_null($expected)) { + $this->assertEquals($expected, $act); + } + } + + public function providerParseIdToken() + { + return [ + 'empty_id_token' => [ + [ + 'access_token' => 'access', + 'refresh_token' => 'refresh', + 'expires_in' => 600, + 'scope' => 'openid', + ], + Loader::loadJson('jwks.json'), + \UnexpectedValueException::class, + ], + 'invalid_id_token_format' => [ + [ + 'access_token' => 'access', + 'refresh_token' => 'refresh', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => 'idt', + ], + Loader::loadJson('jwks.json'), + \UnexpectedValueException::class, + ], + 'not found jwks' => [ + [ + 'access_token' => 'access', + 'refresh_token' => 'refresh', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImR1bW15In0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3QiLCJpYXQiOjAsImV4cCI6MjUzMzcwNzMyNDAwfQ.p_iMMoXAqBV6sXwD8-dnCZvvIDKGvhWLWFaOzY9nbsk8qYnojGI46stgJtlMYy0hdraeGhytB75HiEyrHGTFPBfqWcZErpSkrpcjNKwSZsrjMyz59Qe8bf65o6CFWZfqSFz8DmVwOf2nKcuHZCRhpOmGcHbFDvPJyXjNNBfg2XftFpNHvkwJYQ2_QIzflHPoQ0YiH6babGwp_bnyLAv7HVLxyWkZkFuE1DX0CSWLpKRaLUbMiKwg33AqDDiQOMUTJxabWbEzUavY_xvnDX67tMEwClVIIq6YIYgwKrtn0r_M64Oy9TRGSKkQO-rDcGXkShcRh_AT3alTuEfxY-e50Q', + ], + Loader::loadJson('jwks.json'), + NotFoundException::class, + ], + 'invalid signature' => [ + [ + 'access_token' => 'access', + 'refresh_token' => 'refresh', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Iks0K0tVdTYydWZpVm9CQ1p2Ym9HZEhJVWJtVTlEM3pIIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3QiLCJpYXQiOjAsImV4cCI6MjUzMzcwNzMyNDAwfQ.NrWoRZ1iT0oxUAqCGUTXHXx06tASbeACfWm04-2F0eKNL9L8RPxXctm0WVcwyt0cT6YKeVZV3oUJDbkhVoagC5_xo2nQQoz30oPDuxS1D1KwcdBfbP4x49iLWc4O_kBah2gNwBREpuoUQxYoD8Vnzk8IZ5sr2kRmKwqzNP5S1uh1rUUT3knGqsmmnluneiDocBirYbWqV95_kpBbpcNBh9_Z8IAoyQK1s0SMLRXd03brVOwWtnZaFO9GjK6kzAy4lwOMeIZB67ftTZCk-5jJY4jhaX720FQKLMgmMpXmi3UZ9OT_tBdhmv6XG8M8c6PUydjHrCLjoviEVH-7c13SWx', + ], + Loader::loadJson('jwks.json'), + InvalidTokenException::class, + ], + 'invalid iss' => [ + [ + 'access_token' => 'access', + 'refresh_token' => 'refresh', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Iks0K0tVdTYydWZpVm9CQ1p2Ym9HZEhJVWJtVTlEM3pIIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdC9pc3N1ZXJ4IiwiYXVkIjoiYWJjIiwiYXRfaGFzaCI6Im9GWWYxa25OdHJxbmhBVmZCUnV0ZVEiLCJuYW1lIjoidGVzdCIsImlhdCI6MCwiZXhwIjoyNTMzNzA3MzI0MDB9.XCgdgtPRJADTOR8h-YyjCjbvtBZE7EAld7009r64cuWWn20NAeqQSV6bH20ouAXYxl8CTkI2QS6zHj2rhOfkd54Os7YaqALVdkTxSagoYddXe8ofuWgaAvJ5xe0P4TyYbCkO5UN1DHW11-sW_cBKJHtBGMrPk-o_2EFydXTfn4XLxQhfDSqVvQjn1LKk6oWM3biWGJGNIa8184TAaXwIR_hBoZJGQZvlm5mmuy2QBdzZ5m6NZiO0rAJDvphM5aseqOhKGarwI0Lp7OE6zL7os00piVwHzog_WuVQRgdVKZt6gM6ugoUwoEM-rfV1OuvzNA43W11xWWuX9kMLXLMwMg', + ], + Loader::loadJson('jwks.json'), + InvalidTokenException::class, + ], + 'invalid aud' => [ + [ + 'access_token' => 'access', + 'refresh_token' => 'refresh', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Iks0K0tVdTYydWZpVm9CQ1p2Ym9HZEhJVWJtVTlEM3pIIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdC9pc3N1ZXIiLCJhdWQiOiJhYmN4IiwiYXRfaGFzaCI6Im9GWWYxa25OdHJxbmhBVmZCUnV0ZVEiLCJuYW1lIjoidGVzdCIsImlhdCI6MCwiZXhwIjoyNTMzNzA3MzI0MDB9.ljf7faNM4thjxbP5Kwv1bKDtT7nJUVurHtnSWGflvNpK-uGGzaYJGAntsRcUSeYgQJ6ZsKRdkE3F7AZ7Pnf1v3HyHcgOVAM70x1zVNvzZanA5A3O1llyks6qLetkSkM80g8M7bNrArxzhyo_px3ox1PoAnu_qyzv6lHgst48cnwSX2orVWhP-q-4RYExDsR_G1OfZvIC6ClP-4TQMHXDtSO80eZjtlk_I-Ra-O-IMaf3ZUU3lydmww4Fo5_ryXgS6ELAaAqh1qnropa8miHQPVJM1LXmfkj-Y1YH0zmGyCL_nwJBY-DOcoGYW8GmSUGSvSgQbUBm4XeXV-1XwH1s6g', + ], + Loader::loadJson('jwks.json'), + InvalidTokenException::class, + ], + 'invalid at_hash' => [ + [ + 'access_token' => 'access', + 'refresh_token' => 'refresh', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Iks0K0tVdTYydWZpVm9CQ1p2Ym9HZEhJVWJtVTlEM3pIIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdC9pc3N1ZXIiLCJhdWQiOiJhYmMiLCJhdF9oYXNoIjoib0ZZZjFrbk50cnFuaEFWZkJSdXRlUXgiLCJuYW1lIjoidGVzdCIsImlhdCI6MCwiZXhwIjoyNTMzNzA3MzI0MDB9.H-BqQwmtJBpmKQROwhsyxriIU5JtZ5y4gCJDp9ggskluI72Qm5vTqp-0o2ViD26Qxgeve6YILD8EXKD6EZYHjYSrQxxyVIcwvm_oVHe9Ks9dkIX9qKJ1Jj5y0xMnIPAYkwu9YZtCxCvlUbZRu4CChBuhRFL-08ANCnS1LZZ4K3_pN9wazgEgdmJWO_RaZszFW6Z7SXvT1Qm4KdEKIjYtRBqTjNKX7a-hExjKSa_2btWCj7sA-koKrFqMAelRX4sfC9mFF4Z6RTPJPzy5ORXWLYz3-wnQmPhT2mcZoAteZJu4Iv_DcUOHfgPzcpVx-oV66StHoeSyvt6HqxYzVBUBTg', + ], + Loader::loadJson('jwks.json'), + InvalidTokenException::class, + ], + 'valid minimum token' => [ + [ + 'access_token' => 'access', + 'refresh_token' => 'refresh', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Iks0K0tVdTYydWZpVm9CQ1p2Ym9HZEhJVWJtVTlEM3pIIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdC9pc3N1ZXIiLCJhdWQiOiJhYmMiLCJhdF9oYXNoIjoib0ZZZjFrbk50cnFuaEFWZkJSdXRlUSIsIm5hbWUiOiJ0ZXN0IiwiaWF0IjowLCJleHAiOjI1MzM3MDczMjQwMH0.ZND6916mdEGKDASfacevMZKsVw6oQ1SLFcJkzzkNmZZBqgiEeRpaT8MccTLnscUzeV1FqpIIFMfzT-7eI-Du8202ygNBVPdZl2JlS8Y1BWPMqYENvR4-fb6-Ojicd4ksj51pkul5o0nspaoUpUSNOwN6puKLz-Q0g3SzoUrG9m5t8ZYANl-RmdGCu6fG-qfTG_8O8UPJ9hYstf8x9PR8SLOES8do5JU3OOJxaL3YdFN4DFLcI_qbYmr5i1jB_UDTXLxeKAuOj88PORHnblyQpwLKtH7H0Dlk94s7JRh8pNowofIC3PRoIlH_RS-XInDjGq2HJqGwz6lW22WkLRbiIQ', + ], + Loader::loadJson('jwks.json'), + null, + null, + [ + 'header' => [ + 'alg' => 'RS256', + 'typ' => 'JWT', + 'kid' => 'K4+KUu62ufiVoBCZvboGdHIUbmU9D3zH', + ], + 'payload' => [ + 'sub' => '1234567890', + 'iss' => 'http://localhost/issuer', + 'aud' => 'abc', + 'at_hash' => 'oFYf1knNtrqnhAVfBRuteQ', + 'name' => 'test', + 'iat' => 0, + 'exp' => 253370732400, + ], + ], + ], + 'nonce arg: null, included nonce on token' => [ + [ + 'access_token' => 'access', + 'refresh_token' => 'refresh', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Iks0K0tVdTYydWZpVm9CQ1p2Ym9HZEhJVWJtVTlEM3pIIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdC9pc3N1ZXIiLCJhdWQiOiJhYmMiLCJhdF9oYXNoIjoib0ZZZjFrbk50cnFuaEFWZkJSdXRlUSIsIm5vbmNlIjoiZHVtbXktbm9uY2UiLCJuYW1lIjoidGVzdCIsImlhdCI6MCwiZXhwIjoyNTMzNzA3MzI0MDB9.sfskqb2Hu2c8VMYm-A3dRZSzLt-zdhqgtk7T81pnn7iZas9sXCMNms1XJED4D9Gnfd8gyqtcn2zXRQ5NR3YARj8pSfjRqUxiSXfFpHXt77K4GJNjws04rE5UgWaTr2PBBBpsn8-IUY5CrJYP6GgIdptJh8XGmiOr1XH-p6uJ3efFKlhAQ140_jUYr32Q7QQeXqpE7T56WLKdHLSYiRKZix_Ht5WqjuNJIa5ZFQHSMTyi1sRj0E_5dmlP0OQKHqffHCWduoHuE3kh-DxKeNwlrioQX0Gurw4V96vj_GkynNbQ3uVepv2t4IFHh23XTnp-SoZm-DjyJnto6hUJk8sE8Q', + ], + Loader::loadJson('jwks.json'), + InvalidTokenException::class, + ], + 'nonce arg: missing, included nonce on token' => [ + [ + 'access_token' => 'access', + 'refresh_token' => 'refresh', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Iks0K0tVdTYydWZpVm9CQ1p2Ym9HZEhJVWJtVTlEM3pIIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdC9pc3N1ZXIiLCJhdWQiOiJhYmMiLCJhdF9oYXNoIjoib0ZZZjFrbk50cnFuaEFWZkJSdXRlUSIsIm5vbmNlIjoiZHVtbXktbm9uY2UiLCJuYW1lIjoidGVzdCIsImlhdCI6MCwiZXhwIjoyNTMzNzA3MzI0MDB9.sfskqb2Hu2c8VMYm-A3dRZSzLt-zdhqgtk7T81pnn7iZas9sXCMNms1XJED4D9Gnfd8gyqtcn2zXRQ5NR3YARj8pSfjRqUxiSXfFpHXt77K4GJNjws04rE5UgWaTr2PBBBpsn8-IUY5CrJYP6GgIdptJh8XGmiOr1XH-p6uJ3efFKlhAQ140_jUYr32Q7QQeXqpE7T56WLKdHLSYiRKZix_Ht5WqjuNJIa5ZFQHSMTyi1sRj0E_5dmlP0OQKHqffHCWduoHuE3kh-DxKeNwlrioQX0Gurw4V96vj_GkynNbQ3uVepv2t4IFHh23XTnp-SoZm-DjyJnto6hUJk8sE8Q', + ], + Loader::loadJson('jwks.json'), + InvalidTokenException::class, + 'dummy', + ], + 'nonce arg: match, included nonce on token' => [ + [ + 'access_token' => 'access', + 'refresh_token' => 'refresh', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Iks0K0tVdTYydWZpVm9CQ1p2Ym9HZEhJVWJtVTlEM3pIIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdC9pc3N1ZXIiLCJhdWQiOiJhYmMiLCJhdF9oYXNoIjoib0ZZZjFrbk50cnFuaEFWZkJSdXRlUSIsIm5vbmNlIjoiZHVtbXktbm9uY2UiLCJuYW1lIjoidGVzdCIsImlhdCI6MCwiZXhwIjoyNTMzNzA3MzI0MDB9.sfskqb2Hu2c8VMYm-A3dRZSzLt-zdhqgtk7T81pnn7iZas9sXCMNms1XJED4D9Gnfd8gyqtcn2zXRQ5NR3YARj8pSfjRqUxiSXfFpHXt77K4GJNjws04rE5UgWaTr2PBBBpsn8-IUY5CrJYP6GgIdptJh8XGmiOr1XH-p6uJ3efFKlhAQ140_jUYr32Q7QQeXqpE7T56WLKdHLSYiRKZix_Ht5WqjuNJIa5ZFQHSMTyi1sRj0E_5dmlP0OQKHqffHCWduoHuE3kh-DxKeNwlrioQX0Gurw4V96vj_GkynNbQ3uVepv2t4IFHh23XTnp-SoZm-DjyJnto6hUJk8sE8Q', + ], + Loader::loadJson('jwks.json'), + null, + 'dummy-nonce', + [ + 'header' => [ + 'alg' => 'RS256', + 'typ' => 'JWT', + 'kid' => 'K4+KUu62ufiVoBCZvboGdHIUbmU9D3zH', + ], + 'payload' => [ + 'sub' => '1234567890', + 'iss' => 'http://localhost/issuer', + 'aud' => 'abc', + 'at_hash' => 'oFYf1knNtrqnhAVfBRuteQ', + 'nonce' => 'dummy-nonce', + 'name' => 'test', + 'iat' => 0, + 'exp' => 253370732400, + ], + ], + ], + 'expire auth_time' => [ + [ + 'access_token' => 'access', + 'refresh_token' => 'refresh', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Iks0K0tVdTYydWZpVm9CQ1p2Ym9HZEhJVWJtVTlEM3pIIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdC9pc3N1ZXIiLCJhdWQiOiJhYmMiLCJhdF9oYXNoIjoib0ZZZjFrbk50cnFuaEFWZkJSdXRlUSIsImF1dGhfdGltZSI6MCwibmFtZSI6InRlc3QiLCJpYXQiOjAsImV4cCI6MjUzMzcwNzMyNDAwfQ.c_FmuzrAADuqnkOv3QE-2KACISQa8n8_xXy-ZR7pKLeKqoDp5IsZvZntgfnXDmBX0Xu6EVgYRNax-kD8vvd2mQlX57wiXcmj4DUTC9UeX6ev2c1lfIX1_F-2FxldvU_DOTSIc2r7JX7Yszizyl-I5s_z4hn3RB5UW-8QdZ5xVNjHj18jE2Msg4IrblPT0Ogx3ao6pvq8UL0efV-GakZP-nAXEZnKlksNAzpwVWDXC_yG_xIGbEyGcLhu84jP3Th7AW9jkvVhDUGmm-ZrNNFN9dZaXWvRPzl1_DTp62Plqf9S9QENPXCpMtwbwdumMGPDEhafFKroT1Qkaao7DsvA9g', + ], + Loader::loadJson('jwks.json'), + InvalidTokenException::class, + ], + 'valid auth_time' => [ + [ + 'access_token' => 'access', + 'refresh_token' => 'refresh', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Iks0K0tVdTYydWZpVm9CQ1p2Ym9HZEhJVWJtVTlEM3pIIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdC9pc3N1ZXIiLCJhdWQiOiJhYmMiLCJhdF9oYXNoIjoib0ZZZjFrbk50cnFuaEFWZkJSdXRlUSIsImF1dGhfdGltZSI6MjUzMzcwNzMyNDAwLCJuYW1lIjoidGVzdCIsImlhdCI6MCwiZXhwIjoyNTMzNzA3MzI0MDB9.LM-ekU-zHuAjZTaT69JFrrB32Cys_2NznafZnUoa5abH89pCZdPIvxpcVyNPlXmPvBVzIJ2rFdnBJ9JtL4uDPcCMiVESKdohsCJdU9Sibo-ssSdbQAXG6dQfIpHJ5Gms-2au1-I7oDdMcKf0Mm5aHtfkHVgnEgEAAghOJTKZBrU8Tb3Pu8VV5SA5yedg3wqTQQknqhThe3ZABXgy8JLjXNLaNIKVfK0J32f3k0QmjXAMFqmydz70qUBXlh44jACak6WojGQR5iXPawooDRiHIIIW-SJup7e-6ADp6Qw-YGIT_IhIuAVFIQC46Y4astP5yJqGoOQfNGGnX3x7anCYlA', + ], + Loader::loadJson('jwks.json'), + null, + null, + [ + 'header' => [ + 'alg' => 'RS256', + 'typ' => 'JWT', + 'kid' => 'K4+KUu62ufiVoBCZvboGdHIUbmU9D3zH', + ], + 'payload' => [ + 'sub' => '1234567890', + 'iss' => 'http://localhost/issuer', + 'aud' => 'abc', + 'at_hash' => 'oFYf1knNtrqnhAVfBRuteQ', + 'auth_time' => 253370732400, + 'name' => 'test', + 'iat' => 0, + 'exp' => 253370732400, + ], + ], + ], + 'valid' => [ + [ + 'access_token' => 'access', + 'refresh_token' => 'refresh', + 'expires_in' => 600, + 'scope' => 'openid', + 'id_token' => 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Iks0K0tVdTYydWZpVm9CQ1p2Ym9HZEhJVWJtVTlEM3pIIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdC9pc3N1ZXIiLCJhdWQiOiJhYmMiLCJhdF9oYXNoIjoib0ZZZjFrbk50cnFuaEFWZkJSdXRlUSIsIm5vbmNlIjoiZHVtbXktbm9uY2UiLCJhdXRoX3RpbWUiOjI1MzM3MDczMjQwMCwibmFtZSI6InRlc3QiLCJpYXQiOjAsImV4cCI6MjUzMzcwNzMyNDAwfQ.dLTpUDwRe-8wrdDin8ApDFoLmjvwm_5_IQ8YStOeS4VUJPVU2AlTAzYW5Fq6IucCm7gkxLHO7wJpoDXlundGVFtuyO9NQg14yIjV2QoVEqGm4cvPxWUnwqhoBRcXjUfYqHDhy2zpcmpLawhwH4cYVbZvyouPfu9LsdHc2MXNwTm4Hf0ZcdmmbBeF1SR-w5BqnEqS5wIN2xk6r8TC9-eSWZRJipgIElbRr0wYUDivq5GoD76ctzbiigoSzr8EYHYyPuPPxE-58pDxkeBrndbNKGTig2D3H5yw5d0a5B1COEstwltFxPl71FdT_Nk31inKS80EPPTIDehCs-G4Js7zng', + ], + Loader::loadJson('jwks.json'), + null, + 'dummy-nonce', + [ + 'header' => [ + 'alg' => 'RS256', + 'typ' => 'JWT', + 'kid' => 'K4+KUu62ufiVoBCZvboGdHIUbmU9D3zH', + ], + 'payload' => [ + 'sub' => '1234567890', + 'iss' => 'http://localhost/issuer', + 'aud' => 'abc', + 'at_hash' => 'oFYf1knNtrqnhAVfBRuteQ', + 'nonce' => 'dummy-nonce', + 'auth_time' => 253370732400, + 'name' => 'test', + 'iat' => 0, + 'exp' => 253370732400, + ], + ], + ] + ]; + } + + private function setupToken(array $body, array $jwks = []) + { + return new Token($body, $jwks, $this->provider, $this->metadata); + } +} diff --git a/tests/Util/Base64UrlTest.php b/tests/Util/Base64UrlTest.php new file mode 100644 index 0000000..d1a265b --- /dev/null +++ b/tests/Util/Base64UrlTest.php @@ -0,0 +1,95 @@ +assertEquals($expected, $actual); + } + + public function providerEncode() + { + return [ + ['OTEwZjIwZWExZThjNTRmZjc1MzI2OWNhNjhkOWJkNWFkM2RkN2FmNjA5ZjhmNzM5YjExYzAzOTQ4NmVmNWYyNg', '910f20ea1e8c54ff753269ca68d9bd5ad3dd7af609f8f739b11c039486ef5f26'], + ['ZTIyZDdhMjhkNDA5NDA4NzNmNDU1MTMxY2MyMmI1MjllMzJmMmE2MjMyMjFjNDkyZjQwYzM2MzYwYzI0ZTkyZA', 'e22d7a28d40940873f455131cc22b529e32f2a623221c492f40c36360c24e92d'], + ['NTg2YzY2MzAyZjE2MjY5ODRiMzY0NmQ1OWY1MGZhNzFhYjBjMDUwMTBjMmY1MjViMjQwYjM3NzJiZjQ3MjIwZQ', '586c66302f1626984b3646d59f50fa71ab0c05010c2f525b240b3772bf47220e'], + ['MTljOWI0ZWRhNGY4YmE2NmI5YzgxODVhOTNiY2QyNjFlZTk0ZWM2NDVkNTRiNzU1YjM0MjlhMTU2ZDAzZTYyZg', '19c9b4eda4f8ba66b9c8185a93bcd261ee94ec645d54b755b3429a156d03e62f'], + ['MjUzNTI5MjA0MDYzNTA5YmI4MjVhMDA5ZDFlNjA4MDNhMGY0ODQwYWNjYjAzYWQyMzY4NjdhNGUyYzZjMDVhZg', '253529204063509bb825a009d1e60803a0f4840accb03ad236867a4e2c6c05af'], + ['YzJkZjY4MDlkOTQ4MDE4MDkzNzY0ZDE4ZDc5ODcyZGQ3ZTZkNWY3MjRjZjYwNmY5NzU1ZTE0Yzc1YWJhNjM4Zg', 'c2df6809d948018093764d18d79872dd7e6d5f724cf606f9755e14c75aba638f'], + ['M2JiOGU3Njk4YWM1NmIwMjhlZjg0YzkwNDIzNmI2NWZmZWVhNzVlMTBkNDViNDI2OWM1NjgyYWRkMmUxMGJjYw', '3bb8e7698ac56b028ef84c904236b65ffeea75e10d45b4269c5682add2e10bcc'], + ['OTVkNDQ2NWE5NjM5ZmZjZWQ3OWFjZTBlM2I1YjcwOTZlNWNiNmJhNGJhZDUyYzRkMGVlMzE5ODhkMTY5MDZmZA', '95d4465a9639ffced79ace0e3b5b7096e5cb6ba4bad52c4d0ee31988d16906fd'], + ['Y2MxNGJkMGI5MjhhNzAwMThjYWNiMzliMmJiOTlmZmVhMjc0NTI4OTE2MWE0NDU2MThmYTc1YjdmOWI1ZjIyNw', 'cc14bd0b928a70018cacb39b2bb99ffea2745289161a445618fa75b7f9b5f227'], + ['OTdkNDhhNTY4MjczYzNhODg3Y2U0ZDNmMTA2N2FlN2Y1NGFiZjliYmMwOTc1NWRiMjA3YzQ2YzVjNGNkYTgzNg', '97d48a568273c3a887ce4d3f1067ae7f54abf9bbc09755db207c46c5c4cda836'], + ['MGNiMGVlMDQwNDE4YWZhYjM5NjIyZDk2NWVkNDU2OWZiZTNmZWM2M2M5ZGZiMzhhOGI0NjA3NTQ5MTY4MjExOA', '0cb0ee040418afab39622d965ed4569fbe3fec63c9dfb38a8b46075491682118'], + ['Mjc1YzdkYTk2Mjk5Mjc5ZTZhNTZiYTg0ZDk4OGU2MmI0MGU4NGY3YjA1YjkwMmRiZTIzNzkxYzdhOGY0NTU1Yw', '275c7da96299279e6a56ba84d988e62b40e84f7b05b902dbe23791c7a8f4555c'], + ['ZGY2NGUzYmQ1NGIxYjJjOWQ3ODcwYWNkNThlMTAzZDczMTIyNjhjNTNlOGVlNTQzMzIzODc3OTdmMzI1OTNhMw', 'df64e3bd54b1b2c9d7870acd58e103d7312268c53e8ee54332387797f32593a3'], + ['ZDlmMGUwZjllZTk3ZTI4ZTAxYzQ3NjA0NDM3NmVlYjQ0MjU1YjFhYjg1ZjMxN2VmZmIyNmNmMTNjNzliYzY2Mw', 'd9f0e0f9ee97e28e01c476044376eeb44255b1ab85f317effb26cf13c79bc663'], + ['MzYzOTI4NDMyMGI1NDM4N2E2NDY5ZTRmM2VmZDIyMDBiYTM3OWY2NTRkNDFiYTU0ODE5MDQ3M2Q0Njk2NzJiYg', '3639284320b54387a6469e4f3efd2200ba379f654d41ba548190473d469672bb'], + ['YWE1NDM4ODgxZThhOTU5NDQ3ZDE3ODZjZjZkNzIzMWQ2MzJlYjQ2ZDFmNDg1ODc3YWE2NGQzY2Y3NDVjMTcxYQ', 'aa5438881e8a959447d1786cf6d7231d632eb46d1f485877aa64d3cf745c171a'], + ['ZjlkNDZmNzJmNGFjMjI4MjllZTZkMjM5M2E4NTA5ZDJmYjhmMzc2NTM1MWQ5N2ZjNTA3NTFmMTZiZDY4NmQ1YQ', 'f9d46f72f4ac22829ee6d2393a8509d2fb8f3765351d97fc50751f16bd686d5a'], + ['N2FKUWF-UmZiZ2xJbEpXOFFlMWFxVEsxUG1lSlhaME5SUUZpRzQtMjdFZGhzM2xGLnVtZDhXNC5iSUlCVlBFQ3VyZnNKclNSS0JOTWJ4WHR4dmVKTjRVODkwT0wzSDlIOUVsVUNFTA', '7aJQa~RfbglIlJW8Qe1aqTK1PmeJXZ0NRQFiG4-27Edhs3lF.umd8W4.bIIBVPECurfsJrSRKBNMbxXtxveJN4U890OL3H9H9ElUCEL'], + ['dHNOd3FDTmpYUDhmdVdUMmppTVplUkV1UjdVbG55TktwMUJyVXE1d0sxcy1UUg', 'tsNwqCNjXP8fuWT2jiMZeREuR7UlnyNKp1BrUq5wK1s-TR'], + ['VHZBaWlIRkptbHE0NXJ6R1IyXzUzdn5TY3oxZ1p2MFVnMEVvZ0ZQNEJhajdCcERlSmN5bkQuZ2V2SGpUUlNUV1ZveEVZSHpWM2V3NEJOQTh0eTh0MlRtblNhUmxfQXRkMThsTDdlUURJUy1XMTJwU1V3RjBnbklP', 'TvAiiHFJmlq45rzGR2_53v~Scz1gZv0Ug0EogFP4Baj7BpDeJcynD.gevHjTRSTWVoxEYHzV3ew4BNA8ty8t2TmnSaRl_Atd18lL7eQDIS-W12pSUwF0gnIO'], + ['SmlOR25vTXpSaEJUSFpYQ1JJZDhSNXVkeTRWNFBGeEc5a2gxbWVfLXcyM0g2ODVNT3EyMw', 'JiNGnoMzRhBTHZXCRId8R5udy4V4PFxG9kh1me_-w23H685MOq23'], + ['WGNhLS04U0FTSzd4SVlCMnphaWhqQWpWVE1xQ1dMbFlzYWJlQWN3NlBpMW5kYWwzM0Y4MzRwbE8uYi4', 'Xca--8SASK7xIYB2zaihjAjVTMqCWLlYsabeAcw6Pi1ndal33F834plO.b.'], + ['c3AwUmI1Z1VJbFQ0Vkp0MVl-cDBxMXFScG41VlBDWV95RkticklrNEVmVDVNdmZqNTdfLmhXLUtHck50Ry1ZM0JtajZlNkZFZC5-MUowUzg3dW85X2V5bHd2Nnd-TW9G', 'sp0Rb5gUIlT4VJt1Y~p0q1qRpn5VPCY_yFKbrIk4EfT5Mvfj57_.hW-KGrNtG-Y3Bmj6e6FEd.~1J0S87uo9_eylwv6w~MoF'], + ['M0cwSVR1QXB1cFo1dUZLWVZ3dGlwOGdrOVUzWDhqNWN2bWJqNjhZcE5RRXFRfkVLdDE5X3lZWjFWbm0tQkppVE5tRXYtSWhERlhrNHd0MA', '3G0ITuApupZ5uFKYVwtip8gk9U3X8j5cvmbj68YpNQEqQ~EKt19_yYZ1Vnm-BJiTNmEv-IhDFXk4wt0'], + ['U1JoX1d5U2VhZFhWQ2NXYlJzMGF-cHNFb0swTGw4RzNQZk5fYnFNWWhzcVJubks', 'SRh_WySeadXVCcWbRs0a~psEoK0Ll8G3PfN_bqMYhsqRnnK'], + ['RkZMOWpNaDMxdXJka0xwdG1EeFZqVXdqd29TNDkyUks2US5fWUhTSkM4cH5KNmhKeXNZWWhETkFabkVCRTdRd2R6V0FYeC01eC5DTWhGR0syMUlUZmFYNE9tanFpOXI3dE5MLUJBMlVVVEl0VkliSXZ0ZWFMOXA2Qm1TWkFR', 'FFL9jMh31urdkLptmDxVjUwjwoS492RK6Q._YHSJC8p~J6hJysYYhDNAZnEBE7QwdzWAXx-5x.CMhFGK21ITfaX4Omjqi9r7tNL-BA2UUTItVIbIvteaL9p6BmSZAQ'] + ]; + } + + /** + * @dataProvider providerDecode + * @param $expected + * @param $text + */ + public function testDecode($expected, $text) + { + $actual = Base64Url::decode($text); + $this->assertEquals($expected, $actual); + } + + public function providerDecode() + { + return [ + ['910f20ea1e8c54ff753269ca68d9bd5ad3dd7af609f8f739b11c039486ef5f26', 'OTEwZjIwZWExZThjNTRmZjc1MzI2OWNhNjhkOWJkNWFkM2RkN2FmNjA5ZjhmNzM5YjExYzAzOTQ4NmVmNWYyNg'], + ['e22d7a28d40940873f455131cc22b529e32f2a623221c492f40c36360c24e92d', 'ZTIyZDdhMjhkNDA5NDA4NzNmNDU1MTMxY2MyMmI1MjllMzJmMmE2MjMyMjFjNDkyZjQwYzM2MzYwYzI0ZTkyZA'], + ['586c66302f1626984b3646d59f50fa71ab0c05010c2f525b240b3772bf47220e', 'NTg2YzY2MzAyZjE2MjY5ODRiMzY0NmQ1OWY1MGZhNzFhYjBjMDUwMTBjMmY1MjViMjQwYjM3NzJiZjQ3MjIwZQ'], + ['19c9b4eda4f8ba66b9c8185a93bcd261ee94ec645d54b755b3429a156d03e62f', 'MTljOWI0ZWRhNGY4YmE2NmI5YzgxODVhOTNiY2QyNjFlZTk0ZWM2NDVkNTRiNzU1YjM0MjlhMTU2ZDAzZTYyZg'], + ['253529204063509bb825a009d1e60803a0f4840accb03ad236867a4e2c6c05af', 'MjUzNTI5MjA0MDYzNTA5YmI4MjVhMDA5ZDFlNjA4MDNhMGY0ODQwYWNjYjAzYWQyMzY4NjdhNGUyYzZjMDVhZg'], + ['c2df6809d948018093764d18d79872dd7e6d5f724cf606f9755e14c75aba638f', 'YzJkZjY4MDlkOTQ4MDE4MDkzNzY0ZDE4ZDc5ODcyZGQ3ZTZkNWY3MjRjZjYwNmY5NzU1ZTE0Yzc1YWJhNjM4Zg'], + ['3bb8e7698ac56b028ef84c904236b65ffeea75e10d45b4269c5682add2e10bcc', 'M2JiOGU3Njk4YWM1NmIwMjhlZjg0YzkwNDIzNmI2NWZmZWVhNzVlMTBkNDViNDI2OWM1NjgyYWRkMmUxMGJjYw'], + ['95d4465a9639ffced79ace0e3b5b7096e5cb6ba4bad52c4d0ee31988d16906fd', 'OTVkNDQ2NWE5NjM5ZmZjZWQ3OWFjZTBlM2I1YjcwOTZlNWNiNmJhNGJhZDUyYzRkMGVlMzE5ODhkMTY5MDZmZA'], + ['cc14bd0b928a70018cacb39b2bb99ffea2745289161a445618fa75b7f9b5f227', 'Y2MxNGJkMGI5MjhhNzAwMThjYWNiMzliMmJiOTlmZmVhMjc0NTI4OTE2MWE0NDU2MThmYTc1YjdmOWI1ZjIyNw'], + ['97d48a568273c3a887ce4d3f1067ae7f54abf9bbc09755db207c46c5c4cda836', 'OTdkNDhhNTY4MjczYzNhODg3Y2U0ZDNmMTA2N2FlN2Y1NGFiZjliYmMwOTc1NWRiMjA3YzQ2YzVjNGNkYTgzNg'], + ['0cb0ee040418afab39622d965ed4569fbe3fec63c9dfb38a8b46075491682118', 'MGNiMGVlMDQwNDE4YWZhYjM5NjIyZDk2NWVkNDU2OWZiZTNmZWM2M2M5ZGZiMzhhOGI0NjA3NTQ5MTY4MjExOA'], + ['275c7da96299279e6a56ba84d988e62b40e84f7b05b902dbe23791c7a8f4555c', 'Mjc1YzdkYTk2Mjk5Mjc5ZTZhNTZiYTg0ZDk4OGU2MmI0MGU4NGY3YjA1YjkwMmRiZTIzNzkxYzdhOGY0NTU1Yw'], + ['df64e3bd54b1b2c9d7870acd58e103d7312268c53e8ee54332387797f32593a3', 'ZGY2NGUzYmQ1NGIxYjJjOWQ3ODcwYWNkNThlMTAzZDczMTIyNjhjNTNlOGVlNTQzMzIzODc3OTdmMzI1OTNhMw'], + ['d9f0e0f9ee97e28e01c476044376eeb44255b1ab85f317effb26cf13c79bc663', 'ZDlmMGUwZjllZTk3ZTI4ZTAxYzQ3NjA0NDM3NmVlYjQ0MjU1YjFhYjg1ZjMxN2VmZmIyNmNmMTNjNzliYzY2Mw'], + ['3639284320b54387a6469e4f3efd2200ba379f654d41ba548190473d469672bb', 'MzYzOTI4NDMyMGI1NDM4N2E2NDY5ZTRmM2VmZDIyMDBiYTM3OWY2NTRkNDFiYTU0ODE5MDQ3M2Q0Njk2NzJiYg'], + ['aa5438881e8a959447d1786cf6d7231d632eb46d1f485877aa64d3cf745c171a', 'YWE1NDM4ODgxZThhOTU5NDQ3ZDE3ODZjZjZkNzIzMWQ2MzJlYjQ2ZDFmNDg1ODc3YWE2NGQzY2Y3NDVjMTcxYQ'], + ['f9d46f72f4ac22829ee6d2393a8509d2fb8f3765351d97fc50751f16bd686d5a', 'ZjlkNDZmNzJmNGFjMjI4MjllZTZkMjM5M2E4NTA5ZDJmYjhmMzc2NTM1MWQ5N2ZjNTA3NTFmMTZiZDY4NmQ1YQ'], + ['7aJQa~RfbglIlJW8Qe1aqTK1PmeJXZ0NRQFiG4-27Edhs3lF.umd8W4.bIIBVPECurfsJrSRKBNMbxXtxveJN4U890OL3H9H9ElUCEL', 'N2FKUWF-UmZiZ2xJbEpXOFFlMWFxVEsxUG1lSlhaME5SUUZpRzQtMjdFZGhzM2xGLnVtZDhXNC5iSUlCVlBFQ3VyZnNKclNSS0JOTWJ4WHR4dmVKTjRVODkwT0wzSDlIOUVsVUNFTA'], + ['tsNwqCNjXP8fuWT2jiMZeREuR7UlnyNKp1BrUq5wK1s-TR', 'dHNOd3FDTmpYUDhmdVdUMmppTVplUkV1UjdVbG55TktwMUJyVXE1d0sxcy1UUg'], + ['TvAiiHFJmlq45rzGR2_53v~Scz1gZv0Ug0EogFP4Baj7BpDeJcynD.gevHjTRSTWVoxEYHzV3ew4BNA8ty8t2TmnSaRl_Atd18lL7eQDIS-W12pSUwF0gnIO', 'VHZBaWlIRkptbHE0NXJ6R1IyXzUzdn5TY3oxZ1p2MFVnMEVvZ0ZQNEJhajdCcERlSmN5bkQuZ2V2SGpUUlNUV1ZveEVZSHpWM2V3NEJOQTh0eTh0MlRtblNhUmxfQXRkMThsTDdlUURJUy1XMTJwU1V3RjBnbklP'], + ['JiNGnoMzRhBTHZXCRId8R5udy4V4PFxG9kh1me_-w23H685MOq23', 'SmlOR25vTXpSaEJUSFpYQ1JJZDhSNXVkeTRWNFBGeEc5a2gxbWVfLXcyM0g2ODVNT3EyMw'], + ['Xca--8SASK7xIYB2zaihjAjVTMqCWLlYsabeAcw6Pi1ndal33F834plO.b.', 'WGNhLS04U0FTSzd4SVlCMnphaWhqQWpWVE1xQ1dMbFlzYWJlQWN3NlBpMW5kYWwzM0Y4MzRwbE8uYi4'], + ['sp0Rb5gUIlT4VJt1Y~p0q1qRpn5VPCY_yFKbrIk4EfT5Mvfj57_.hW-KGrNtG-Y3Bmj6e6FEd.~1J0S87uo9_eylwv6w~MoF', 'c3AwUmI1Z1VJbFQ0Vkp0MVl-cDBxMXFScG41VlBDWV95RkticklrNEVmVDVNdmZqNTdfLmhXLUtHck50Ry1ZM0JtajZlNkZFZC5-MUowUzg3dW85X2V5bHd2Nnd-TW9G'], + ['3G0ITuApupZ5uFKYVwtip8gk9U3X8j5cvmbj68YpNQEqQ~EKt19_yYZ1Vnm-BJiTNmEv-IhDFXk4wt0', 'M0cwSVR1QXB1cFo1dUZLWVZ3dGlwOGdrOVUzWDhqNWN2bWJqNjhZcE5RRXFRfkVLdDE5X3lZWjFWbm0tQkppVE5tRXYtSWhERlhrNHd0MA'], + ['SRh_WySeadXVCcWbRs0a~psEoK0Ll8G3PfN_bqMYhsqRnnK', 'U1JoX1d5U2VhZFhWQ2NXYlJzMGF-cHNFb0swTGw4RzNQZk5fYnFNWWhzcVJubks'], + ['FFL9jMh31urdkLptmDxVjUwjwoS492RK6Q._YHSJC8p~J6hJysYYhDNAZnEBE7QwdzWAXx-5x.CMhFGK21ITfaX4Omjqi9r7tNL-BA2UUTItVIbIvteaL9p6BmSZAQ', 'RkZMOWpNaDMxdXJka0xwdG1EeFZqVXdqd29TNDkyUks2US5fWUhTSkM4cH5KNmhKeXNZWWhETkFabkVCRTdRd2R6V0FYeC01eC5DTWhGR0syMUlUZmFYNE9tanFpOXI3dE5MLUJBMlVVVEl0VkliSXZ0ZWFMOXA2Qm1TWkFR'] + ]; + } +} diff --git a/tests/Util/JsonTest.php b/tests/Util/JsonTest.php new file mode 100644 index 0000000..7807bb4 --- /dev/null +++ b/tests/Util/JsonTest.php @@ -0,0 +1,118 @@ +assertEquals( + '["a","b","c"]', + Json::encode(['a', 'b', 'c']) + ); + $this->assertEquals( + "[\n \"\u3042\"\n]", + Json::encode( + ['あ'], + JSON_PRETTY_PRINT + ) + ); + $this->assertEquals( + "[\n \"あ\"\n]", + Json::encode( + ['あ'], + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE + ) + ); + $this->assertEquals( + "[\n \"あ\"\n]", + Json::encode( + ['あ'], + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE, + 1 + ) + ); + } + + /** + * @throws JsonErrorException + */ + public function testEncodeFailure() + { + $data = [ + 'a' => [ + 'b'=> [ + 'c' => [] + ] + ] + ]; + $this->expectException(JsonErrorException::class); + Json::encode($data, 0, 0); + } + + /** + * @throws JsonErrorException + */ + public function testDecode() + { + $arr = ['name' => 'hoge']; + $obj = new \stdClass(); + $obj->name = 'hoge'; + + $this->assertEquals( + $obj, + Json::decode('{"name":"hoge"}') + ); + $this->assertEquals( + $obj, + Json::decode('{"name":"hoge"}', false) + ); + $this->assertEquals( + $arr, + Json::decode('{"name":"hoge"}', true) + ); + // depth and options + $this->assertEquals( + ['a' => '123456789012345678901234567890'], + Json::decode('{"a":123456789012345678901234567890}', true, 512, JSON_BIGINT_AS_STRING) + ); + } + + /** + * @dataProvider providerDecodeFailure + * @param $params + * @throws JsonErrorException + */ + public function testDecodeFailure($params) + { + $this->expectException(JsonErrorException::class); + Json::decode($params['json'], $params['assoc'] ?? false, $params['depth'] ?? 512, $params['options'] ?? 0); + } + + public function providerDecodeFailure() + { + return [ + [ + [ + 'json' => '{"name": "hoge}' + ], + ], + [ + [ + 'json' => '{"name": "hoge"}', + 'depth' => 1 + ], + ] + ]; + } +} diff --git a/tests/Util/PkceTest.php b/tests/Util/PkceTest.php new file mode 100644 index 0000000..44cb34e --- /dev/null +++ b/tests/Util/PkceTest.php @@ -0,0 +1,81 @@ +assertEquals($expected, $act); + } + + /** + * @return array + */ + public function providerCreateCodeChallenge() + { + return [ + [ + 'a', + 'ypeBEsobvcr6wjGzmiPcTaeG7_gUfE5yuYB3ha_uSLs' + ], + [ + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'ZtNPunH49FD35FWYhT5Tv8I7vRKQJ8uxMaL0_9eHjNA' + ], + [ + 'pFSF1OPCTq19v8QWpnhlxfZ_~dpIXJsltCCv9b976WQYWeFI-UJujAXXh3C6ipK_XRbPTrbw9nklJH-o~nlczH_-k2GvGRYGaYKLBsMCe4jJZe', + 'zqv_jcQ66dct1LUSU9tQDwb6saAhfT5l8oiJUR5_NYI' + ], + [ + '4nQ8D.viLbU_WWgze7r7QRmgbAiKHeR0c20JqWEZ2y6zMk0o_5pMX~8Ag0S4JfsnmjEZnf9c0gFrpQJ9jr2TGBke5nEWApW~BqB', + 'BtutP05cSEZXhjeBAkU8E1DGBHUkjL0bjy0F0qlYQM8' + ], + [ + 'pvPpQqXTjgjEkJly7uCfBMCt-4QLA_FgB4Bo3IGcesqM_h5VTqeWmR_L2ikt3mLQjCZMPXMhWSUPS27X5bNnxclf7cN2RZRsE5fvt1GIw8nh', + '5hzzcD7XOGvHBfg2as9OH9SlLH4iqFOb66V8c_QbP5A' + ], + [ + 'XycrrNCOdFYnBKIuiJrKzeXK3Eq2G3tDHCGVER7iY_Qiuub7KrxReevcRRPpiRPYLCGrrwiGwvKZxA7hAPJzsfKc50JxtKS4rr3iQt', + 'K98K_UW7YIb2czh5KIZSzKzquQHJw1u89_XtNP-moas' + ], + [ + 'uNo5z2Mycjv2uPRDSmgVts2LR01WqWHVc~Iq-6qgEFP6~kD1QhLCD02QlXmsSGIE6HxY5S5~-mqJSOTPJ5k4pTPQL_vSMNakT.DB9jWL2Qolga', + 'GS_BOYqu4S9mRBfh4rbGFmr2WHgg-K5Bsqq2RL1s4Mw' + ], + [ + 'aLucxKpXwwMJrcgnbXIm9bG4p5Bhg7Kr91Nxg4R.h0qupCLcnNyLJsJyuhQL44w48SPOzH_3bRg-YZMo1i4', + 's2ck_fgZHfmmt5siu-5w-3Hi4GsMGjQevKkwxzV2exM' + ], + [ + 'gFlHiS9wP6Aw8UW2yI~G~~RIB1FOX5bTtzGU17fXVSw963Ju1gCONjUe2TmDXxF~yBISQyScX', + 'm9Z65iq8Llg544s3Se4VxXw2kdltpHNz3fn-os9C710' + ], + [ + 's30rY4Xz2RvcpE_H~V2i2lE.8tGPOR~bt7qXjpEokoYBwoXU4VZTRw~FHjPANJuhN08yc9XoEAKGcn4cxy5mFr3KOQVExHREY.HR8c~j9', + 'uqi5ZpipMk-FOkqcrxSpUIiYNnV44iADimcUZeuYmj8' + ], + [ + 'l8ObQ6wuRmQ3r0-pbT5PDkvMIok_MYa~-6l7lZaw9nP6RTu3J0_JQj', + 'nET9uEiSQya-SRCVw5JYC6MlaNHkOyiOCefB3sB-ZTU' + ], + [ + 'eXES8HWOAy5TeWv-HaEfnbbw~k_7xXHqffia1YZ4c_h7.UF.bTuQ1-FkG0cew5jc0X', + 'D_TR0xKO9bZGwN1Y_ki3x1vvqkjnl43rzkVmPRhPIYk' + ] + ]; + } +} diff --git a/tests/Util/ScopeBuilderTest.php b/tests/Util/ScopeBuilderTest.php new file mode 100644 index 0000000..ea920fe --- /dev/null +++ b/tests/Util/ScopeBuilderTest.php @@ -0,0 +1,133 @@ +assertInstanceOf( + ScopeBuilder::class, + ScopeBuilder::make(...$scopes) + ); + } + + public function providerMake() + { + return [ + [['openid']], + [['profile']], + [['openid', 'profile']], + ]; + } + + /** + * @dataProvider providerMakeFailure + * @param array $scopes + */ + public function testMakeFailure(array $scopes) + { + $this->expectException(\UnexpectedValueException::class); + ScopeBuilder::make(...$scopes); + } + + public function providerMakeFailure() + { + return [ + [['']], + [['openid ']], + [[' openid']], + [['alert(1)']], + [['&state=xxx']], + [['?state=xxx']], + [['openid&code_challenge=none']], + [['openid', '']], + [['openid', 'alert(1)']], + [['openid', '&state=xxx']], + [['openid', '?state=xxx']], + [['openid', 'profile&code_challenge=none']], + ]; + } + + public function testIsEmpty() + { + $sb = ScopeBuilder::make(); + $this->assertTrue($sb->isEmpty()); + $sb->add('openid'); + $this->assertFalse($sb->isEmpty()); + } + + /** + * @dataProvider providerValidate + * @param $scope + * @param $expected + */ + public function testValidate($scope, $expected) + { + $sb = ScopeBuilder::make(); + $this->assertEquals($expected, $sb->Validate($scope)); + } + + public function providerValidate() + { + return [ + ['', false], + ['alert(1)', false], + ['&state=xxx', false], + ['?state=xxx', false], + ['openid&code_challenge=none', false], + ['openid profile', false], + ['abc1', true], + ['openid', true], + ['profile', true], + ['profile.name', true], + ['profile-name', true], + ['profile_name', true], + ]; + } + + public function testExists() + { + { + $sb = ScopeBuilder::make(); + $this->assertFalse($sb->exists('openid')); + $this->assertFalse($sb->exists('profile')); + } + { + $sb = ScopeBuilder::make(); + $sb->add('openid')->add('profile'); + $this->assertTrue($sb->exists('openid')); + $this->assertTrue($sb->exists('profile')); + } + { + $sb = ScopeBuilder::make(); + $sb->add('openid', 'profile'); + $this->assertTrue($sb->exists('openid')); + $this->assertTrue($sb->exists('profile')); + } + } + + public function testBuild() + { + $sb = ScopeBuilder::make(); + $this->assertEquals('', $sb->build()); + $sb->add('openid'); + $this->assertEquals('openid', $sb->build()); + $sb->add('openid'); + $this->assertEquals('openid', $sb->build()); + $sb->add('profile'); + $this->assertEquals('openid profile', $sb->build()); + $sb->add('profile'); + $this->assertEquals('openid profile', $sb->build()); + } +} diff --git a/vendor-bin/php-cs-fixer/composer.json b/vendor-bin/php-cs-fixer/composer.json new file mode 100644 index 0000000..f0b7eda --- /dev/null +++ b/vendor-bin/php-cs-fixer/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "friendsofphp/php-cs-fixer": "^3.4" + } +} diff --git a/vendor-bin/phpstan/composer.json b/vendor-bin/phpstan/composer.json new file mode 100644 index 0000000..2a04375 --- /dev/null +++ b/vendor-bin/phpstan/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "phpstan/phpstan": "^1.7" + } +}