diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..46d48db --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[*.j2] +insert_final_newline = false diff --git a/.github/workflows/php74.yml b/.github/workflows/php74.yml new file mode 100644 index 0000000..b650e14 --- /dev/null +++ b/.github/workflows/php74.yml @@ -0,0 +1,27 @@ +name: PHP 7.4 + +on: push + +jobs: + php74: + name: Check PHP 7.4 + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Install dependencies + uses: php-actions/composer@v5 + with: + php_version: 7.4 + args: --prefer-dist --ignore-platform-reqs + + - name: Check code styling + run: composer ecs-check + + - name: Create keys + run: ./bin/create_keys + + - name: Run unit and feature tests + run: composer test diff --git a/.github/workflows/php80.yml b/.github/workflows/php80.yml new file mode 100644 index 0000000..c88959e --- /dev/null +++ b/.github/workflows/php80.yml @@ -0,0 +1,27 @@ +name: PHP 8.0 + +on: push + +jobs: + php80: + name: Check PHP 8.0 + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Install dependencies + uses: php-actions/composer@v5 + with: + php_version: 8.0 + args: --prefer-dist --ignore-platform-reqs + + - name: Check code styling + run: composer ecs-check + + - name: Create keys + run: ./bin/create_keys + + - name: Run unit and feature tests + run: composer test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9caca6e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +vendor/ +.phpunit.result.cache +composer.lock +tmp/ +dev/ +.phplint-cache +phpd.log diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fdb9e2f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +## 1.0.0 (2021/../..) +* Initial version diff --git a/LICENCE.txt b/LICENCE.txt new file mode 100644 index 0000000..2ddeb56 --- /dev/null +++ b/LICENCE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Ron van der Heijden + +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..271e0b8 --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# OpenID Connect + +This package adds the OpenID Connect identity layer to the PHP League's OAuth2 Server. + +**With [Laravel Passport](https://laravel.com/docs/8.x/passport) support!** + +## Requirements + +* Requires PHP version `^7.4|^8.0`. +* [lcobucci/jwt](https://github.com/lcobucci/jwt) version `^4.0`. +* [league/oauth2-server](https://github.com/thephpleague/oauth2-server) `^8.2`. + +## Installation +```sh +composer require ronvanderheijden/openid-connect +``` + +## Keys + +To sign and encrypt the tokens, we need a private and a public key. +```sh +mkdir -m 700 -p tmp + +openssl genrsa -out tmp/private.key 2048 +openssl rsa -in tmp/private.key -pubout -out tmp/public.key + +chmod 600 tmp/private.key +chmod 644 tmp/public.key +``` + +## Example +I recommand to [read this](https://oauth2.thephpleague.com/authorization-server/auth-code-grant/) first. + +To enable OpenID Connect, follow these simple steps + +```php +$privateKeyPath = 'tmp/private.key'; + +// create the response_type +$responseType = new IdTokenResponse( + new IdentityRepository(), + new ClaimExtractor(), + Configuration::forSymmetricSigner( + new Sha256(), + InMemory::file($privateKeyPath), + ), +); + +$server = new \League\OAuth2\Server\AuthorizationServer( + $clientRepository, + $accessTokenRepository, + $scopeRepository, + $privateKeyPath, + $encryptionKey, + // add the response_type + $responseType, +); +``` + +Now when calling the `/authorize` endpoint, provide the `openid` scope to get an `id_token`. +Provide more scopes (e.g. `openid profile email`) to receive additional claims in the `id_token`. + +For a complete implementation, visit [the OAuth2 Server example](https://github.com/ronvanderheijden/openid-connect/tree/main/example). + +## Laravel Passport + +You can use this package with Laravel Passport. + +### add the service provider +```php +# config/app.php +'providers' => [ + /* + * Package Service Providers... + */ + OpenIDConnect\Laravel\PassportServiceProvider::class, +], +``` + +### create an entity +Create an entity class in `app/Entities/` named `IdentityEntity` or `UserEntity`. This entity is used to collect the claims. +```php +# app/Entities/IdentityEntity.php +namespace App\Entities; + +use League\OAuth2\Server\Entities\Traits\EntityTrait; +use OpenIDConnect\Claims\Traits\WithClaims; +use OpenIDConnect\Interfaces\IdentityEntityInterface; + +class IdentityEntity implements IdentityEntityInterface +{ + use EntityTrait; + use WithClaims; + + /** + * The user to collect the additional information for + */ + protected User $user; + + /** + * The identity repository creates this entity and provides the user id + * @param mixed $identifier + */ + public function setIdentifier($identifier): void + { + $this->identifier = $identifier; + $this->user = User::findOrFail($identifier); + } + + /** + * When building the id_token, this entity's claims are collected + */ + public function getClaims(): array + { + return [ + 'email' => $this->user->email, + ]; + } +} +``` + +### Publishing the config +```sh +php artisan vendor:publish --tag=openidconnect +``` + +In this config, you can change the default scopes, add custom claim sets and change the repositories. + +## Support +Found a bug? Got a feature request? [Create an issue](https://github.com/ronvanderheijden/openid-connect/issues). + +## License +OpenID Connect is open source and licensed under [the MIT licence](https://github.com/ronvanderheijden/openid-connect/blob/master/LICENSE.txt). diff --git a/bin/create_keys b/bin/create_keys new file mode 100755 index 0000000..7251b99 --- /dev/null +++ b/bin/create_keys @@ -0,0 +1,13 @@ +#!/usr/bin/env sh + +set -eu + +if [ ! -f "$(pwd)/tmp/private.key" ]; then + mkdir -m 700 -p tmp + + openssl genrsa -out tmp/private.key 2048 + openssl rsa -in tmp/private.key -pubout -out tmp/public.key + + chmod 600 tmp/private.key + chmod 644 tmp/public.key +fi diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..5222985 --- /dev/null +++ b/composer.json @@ -0,0 +1,69 @@ +{ + "name": "ronvanderheijden/openid-connect", + "type": "library", + "description": "This package adds the OpenID Connect identity layer to the PHP League's OAuth2 Server. With Laravel Passport support.", + "version": "0.1.0", + "license": "MIT", + "homepage": "https://github.com/ronvanderheijden/openid-connect", + "authors": [ + { + "name": "Ron van der Heijden", + "email": "r.heijden@live.nl" + } + ], + "keywords": [ + "openid", + "openid-connect", + "oidc", + "oauth", + "oauth2", + "laravel", + "passport" + ], + "require": { + "php": "^7.4|^8.0", + "lcobucci/jwt": "^4.0", + "league/oauth2-server": "^8.2.0" + }, + "require-dev": { + "guzzlehttp/psr7": "^1.7", + "http-interop/http-factory-guzzle": "^1.0", + "overtrue/phplint": "^2.3", + "phpunit/phpunit": "^9.4.2", + "slevomat/coding-standard": "^6.4.1", + "slim/slim": "4.*", + "symplify/easy-coding-standard": "^9.2", + "league/oauth2-client": "^2.6" + }, + "autoload": { + "psr-4": { + "OpenIDConnect\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "OpenIDConnect\\Example\\": "example/", + "OpenIDConnect\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "vendor/bin/phpunit", + "dev": "vendor/bin/phpunit --group dev", + "ecs-check": "vendor/bin/ecs check", + "ecs-fix": "vendor/bin/ecs check --fix", + "lint": "vendor/bin/phplint --exclude=vendor .", + "fix": [ + "composer update", + "composer ecs-fix", + "composer check" + ], + "check": [ + "composer lint", + "composer ecs-check", + "composer test", + "composer check-platform-reqs", + "composer outdated --direct --no-ansi", + "composer outdated --minor-only --strict --direct" + ] + } +} diff --git a/ecs.php b/ecs.php new file mode 100644 index 0000000..c5b6148 --- /dev/null +++ b/ecs.php @@ -0,0 +1,84 @@ +services(); + + $services->set(FileHeaderSniff::class); + $services->set(TraitUseDeclarationSniff::class); + $services->set(DisallowLongArraySyntaxSniff::class); + $services->set(DeclareStrictTypesFixer::class); + $services->set(UnusedUsesSniff::class); + $services->set(UseFromSameNamespaceSniff::class); + $services->set(OptimizedFunctionsWithoutUnpackingSniff::class); + $services->set(DeadCatchSniff::class); + $services->set(RequireTrailingCommaInCallSniff::class); + $services->set(RequireTrailingCommaInDeclarationSniff::class); + $services->set(RequireConstructorPropertyPromotionSniff::class); + $services->set(AlphabeticallySortedUsesSniff::class); + $services->set(ClassConstantVisibilitySniff::class); + $services->set(TrailingArrayCommaSniff::class); + $services->set(ArrayIndentSniff::class); + $services->set(ClassMemberSpacingSniff::class); + $services->set(CastSpacingSniff::class); + $services->set(SpaceAfterCastSniff::class); + $services->set(LineLengthSniff::class) + ->property('absoluteLineLimit', 120); + $services->set(FunctionSpacingSniff::class) + ->property('spacing', 1) + ->property('spacingBeforeFirst', 0) + ->property('spacingAfterLast', 0); + $services->set(PropertySpacingSniff::class) + ->property('minLinesCountBeforeWithComment', 1) + ->property('maxLinesCountBeforeWithComment', 1) + ->property('minLinesCountBeforeWithoutComment', 0) + ->property('maxLinesCountBeforeWithoutComment', 1); + $services->set(ConstantSpacingSniff::class) + ->property('minLinesCountBeforeWithComment', 1) + ->property('maxLinesCountBeforeWithComment', 1) + ->property('minLinesCountBeforeWithoutComment', 0) + ->property('maxLinesCountBeforeWithoutComment', 1); + $services->set(EmptyLinesAroundClassBracesSniff::class) + ->property('linesCountAfterOpeningBrace', 0) + ->property('linesCountBeforeClosingBrace', 0); + $services->set(BinaryOperatorSpacesFixer::class) + ->call('configure', [ + ['default' => BinaryOperatorSpacesFixer::SINGLE_SPACE], + ]); + + $parameters = $containerConfigurator->parameters(); + + $parameters->set(Option::PATHS, [__DIR__]); + $parameters->set(Option::SETS, [ + SetList::PSR_12, + ]); +}; diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..0a57637 --- /dev/null +++ b/example/README.md @@ -0,0 +1,14 @@ +# Example + +This is an example to Mock an OAuth 2.0 server with OpenID Connect implemented. + +I recommand to [read this](https://oauth2.thephpleague.com/authorization-server/auth-code-grant/) first. + +## Setup +```sh +# start the service application +php -S localhost:8000 -t example > phpd.log 2>&1 & + +# get the tokens using the client +php example/get_tokens +``` diff --git a/example/get_tokens b/example/get_tokens new file mode 100755 index 0000000..4154bca --- /dev/null +++ b/example/get_tokens @@ -0,0 +1,172 @@ +#!/usr/bin/php +randomString(40); + +/** + * A verifier string which will be used to hash a code_challenge. + */ +$codeVerifier = $crawler->codeVerifier(); + +/** + * A hashed code, using the code_verifier to send to the Auth Provider. + */ +$codeChallenge = $crawler->codeChallenge($codeVerifier); + +/** + * Do the authorization request. + */ +$redirectParams = $crawler->httpRequest($authorizationUrl, [ + 'client_id' => (string) $clientId, + 'redirect_uri' => $redirectUrl, + 'response_type' => 'code', + 'scope' => $scopes, + 'state' => $state, + 'code_challenge' => $codeChallenge, + 'code_challenge_method' => 'S256', +]); + +if (!$redirectParams) { + throw new Exception('No redirect received!'); +} + +/** + * We expect a redirect URL after requesting the auth code. + * 1. We should verify the $_GET['state'] with our $state + * 2. We should post back the $_GET['code'] and our code_verifier + */ +$callbackComponents = parse_url($redirectParams); +parse_str($callbackComponents['query'], $callbackQuery); + +if (!$code = $callbackQuery['code']) { + throw new Exception('Code not found!'); +} + +if (!$receivedState = $callbackQuery['state']) { + throw new Exception('State not found!'); +} + +if ($state !== $receivedState) { + throw new Exception('States do not match!'); +} + +$content = $crawler->httpRequest($tokensUrl, [ + 'grant_type' => 'authorization_code', + 'client_id' => (string) $clientId, + 'redirect_uri' => $redirectUrl, + 'code_verifier' => $codeVerifier, + 'code' => $code, +], true); + +if (!$content) { + throw new Exception('No token response received'); +} + +$content = json_decode($content, true); +if (!$content) { + throw new Exception('No valid json received'); +} + +$dump = [ + 'token_type' => $content['token_type'], + 'expires_in' => $content['expires_in'], + 'refresh_token' => $content['refresh_token'], + 'access_token' => $crawler->parseJwt($content['access_token']), +]; + +if (isset($content['id_token'])) { + $dump['id_token'] = $crawler->parseJwt($content['id_token']); +} + +print_r($dump); diff --git a/example/index.php b/example/index.php new file mode 100644 index 0000000..22ec4dc --- /dev/null +++ b/example/index.php @@ -0,0 +1,136 @@ +setRefreshTokenTTL(new \DateInterval('P1M')); // refresh tokens will expire after 1 month + +// Enable the authentication code grant on the server +$server->enableGrantType( + $grant, + new \DateInterval('PT1H') // access tokens will expire after 1 hour +); + +$app = AppFactory::create(); + +$app->get('/authorize', function ( + ServerRequestInterface $request, + ResponseInterface $response +) use ($server) { + try { + // Validate the HTTP request and return an AuthorizationRequest object. + $authRequest = $server->validateAuthorizationRequest($request); + + // The auth request object can be serialized and saved into a user's session. + // You will probably want to redirect the user at this point to a login endpoint. + $user = new IdentityEntity(); + $user->setIdentifier('1'); + + // Once the user has logged in set the user on the AuthorizationRequest + $authRequest->setUser($user); // an instance of UserEntityInterface + + // At this point you should redirect the user to an authorization page. + // This form will ask the user to approve the client and the scopes requested. + + // Once the user has approved or denied the client update the status + // (true = approved, false = denied) + $authRequest->setAuthorizationApproved(true); + + // Return the HTTP redirect response + return $server->completeAuthorizationRequest($authRequest, $response); + } catch (OAuthServerException $exception) { + // All instances of OAuthServerException can be formatted into a HTTP response + return $exception->generateHttpResponse($response); + } catch (Exception $exception) { + // Unknown exception + $body = new Psr7\Stream(fopen('php://temp', 'r+')); + $body->write($exception->getMessage()); + return $response->withStatus(500)->withBody($body); + } +}); + +$app->post('/tokens', function ( + ServerRequestInterface $request, + ResponseInterface $response +) use ($server) { + try { + // Try to respond to the request + return $server->respondToAccessTokenRequest($request, $response); + } catch (\League\OAuth2\Server\Exception\OAuthServerException $exception) { + // All instances of OAuthServerException can be formatted into a HTTP response + return $exception->generateHttpResponse($response); + } catch (Exception $exception) { + // Unknown exception + $body = new Psr7\Stream(fopen('php://temp', 'r+')); + $body->write($exception->getMessage()); + return $response->withStatus(500)->withBody($body); + } +}); + +$app->run(); diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..bcfdbc0 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,28 @@ + + + + + + ./tests/Unit + + + ./tests/Feature + + + + + + src + + + diff --git a/src/ClaimExtractor.php b/src/ClaimExtractor.php new file mode 100644 index 0000000..b1afca7 --- /dev/null +++ b/src/ClaimExtractor.php @@ -0,0 +1,123 @@ +addClaimSet($this->profile()) + ->addClaimSet($this->email()) + ->addClaimSet($this->address()) + ->addClaimSet($this->phone()); + + foreach ($claimSets as $claimSet) { + $this->addClaimSet($claimSet); + } + } + + public function getProtectedClaims(): array + { + return ['profile', 'email', 'address', 'phone']; + } + + /** @throws ProtectedScopeException */ + public function addClaimSet(ClaimSetInterface $claimSet): self + { + $scope = $claimSet->getScope(); + + if (in_array($scope, $this->getProtectedClaims()) && !empty($this->claimSets[$scope])) { + throw new ProtectedScopeException($scope); + } + $this->claimSets[$scope] = $claimSet; + + return $this; + } + + public function getClaimSet(string $scope): ?ClaimSetInterface + { + return $this->hasClaimSet($scope) ? $this->claimSets[$scope] : null; + } + + public function hasClaimSet(string $scope): bool + { + return array_key_exists($scope, $this->claimSets); + } + + public function extract(array $scopes, array $claims): array + { + $extracted = []; + foreach ($scopes as $scope) { + if ($scope instanceof ScopeEntityInterface) { + $scope = $scope->getIdentifier(); + } + + if (!$claimSet = $this->getClaimSet($scope)) { + continue; + } + + $intersected = array_intersect($claimSet->getClaims(), array_keys($claims)); + + $extracted = array_merge( + $extracted, + array_filter($claims, function ($key) use ($intersected) { + return in_array($key, $intersected); + }, ARRAY_FILTER_USE_KEY) + ); + } + return $extracted; + } + + private function profile(): ClaimSetInterface + { + return new ClaimSet('profile', [ + 'name', + 'family_name', + 'given_name', + 'middle_name', + 'nickname', + 'preferred_username', + 'profile', + 'picture', + 'website', + 'gender', + 'birthdate', + 'zoneinfo', + 'locale', + 'updated_at', + ]); + } + + private function email(): ClaimSetInterface + { + return new ClaimSet('email', [ + 'email', + 'email_verified', + ]); + } + + private function address(): ClaimSetInterface + { + return new ClaimSet('address', [ + 'address', + ]); + } + + private function phone(): ClaimSetInterface + { + return new ClaimSet('phone', [ + 'phone_number', + 'phone_number_verified', + ]); + } +} diff --git a/src/Claims/ClaimSet.php b/src/Claims/ClaimSet.php new file mode 100644 index 0000000..0e90984 --- /dev/null +++ b/src/Claims/ClaimSet.php @@ -0,0 +1,20 @@ +scope = $scope; + $this->claims = $claims; + } +} diff --git a/src/Claims/ClaimSetInterface.php b/src/Claims/ClaimSetInterface.php new file mode 100644 index 0000000..0265627 --- /dev/null +++ b/src/Claims/ClaimSetInterface.php @@ -0,0 +1,9 @@ +claims; + } + + public function setClaims(array $claims): void + { + $this->claims = $claims; + } +} diff --git a/src/Claims/Traits/WithScope.php b/src/Claims/Traits/WithScope.php new file mode 100644 index 0000000..3941b53 --- /dev/null +++ b/src/Claims/Traits/WithScope.php @@ -0,0 +1,15 @@ +scope; + } +} diff --git a/src/Entities/AccessTokenEntity.php b/src/Entities/AccessTokenEntity.php new file mode 100644 index 0000000..fa04f0d --- /dev/null +++ b/src/Entities/AccessTokenEntity.php @@ -0,0 +1,17 @@ +name = $name; + } + + public function setRedirectUri($uri) + { + $this->redirectUri = $uri; + } + + public function setConfidential() + { + $this->isConfidential = true; + } +} diff --git a/src/Entities/IdentityEntity.php b/src/Entities/IdentityEntity.php new file mode 100644 index 0000000..9c813e6 --- /dev/null +++ b/src/Entities/IdentityEntity.php @@ -0,0 +1,42 @@ + 'Jon Snow', + 'nickname' => 'The Bastard of Winterfell', + + // email + 'email' => 'jon.snow@dorne.com', + 'email_verified' => true, + + // phone + 'phone_number' => '0031 493 123 456', + 'phone_number_verified' => true, + + // address + 'address' => 'Castle Black, The Night\'s Watch, The North', + + // custom + 'what_he_knows' => 'Nothing!', + ]; + } +} diff --git a/src/Entities/RefreshTokenEntity.php b/src/Entities/RefreshTokenEntity.php new file mode 100644 index 0000000..e85f6f2 --- /dev/null +++ b/src/Entities/RefreshTokenEntity.php @@ -0,0 +1,15 @@ +identityRepository = $identityRepository; + $this->claimExtractor = $claimExtractor; + $this->config = $config; + } + + protected function getBuilder( + AccessTokenEntityInterface $accessToken, + IdentityEntityInterface $userEntity, + ): Builder { + $dateTimeImmutableObject = new DateTimeImmutable(); + + return $this->config + ->builder() + ->permittedFor($accessToken->getClient()->getIdentifier()) + ->issuedBy('https://' . $_SERVER['HTTP_HOST']) + ->issuedAt($dateTimeImmutableObject) + ->expiresAt($dateTimeImmutableObject->add(new DateInterval('PT1H'))) + ->relatedTo($userEntity->getIdentifier()); + } + + protected function getExtraParams(AccessTokenEntityInterface $accessToken): array + { + if (!$this->hasOpenIDScope(...$accessToken->getScopes())) { + return []; + } + + $user = $this->identityRepository->getByIdentifier( + (string) $accessToken->getUserIdentifier(), + ); + + $builder = $this->getBuilder($accessToken, $user); + + $claims = $this->claimExtractor->extract( + $accessToken->getScopes(), + $user->getClaims(), + ); + + foreach ($claims as $claimName => $claimValue) { + $builder = $builder->withClaim($claimName, $claimValue); + } + + $token = $builder->getToken( + $this->config->signer(), + $this->config->signingKey(), + ); + + return ['id_token' => $token->toString()]; + } + + private function hasOpenIDScope(ScopeEntityInterface ...$scopes): bool + { + foreach ($scopes as $scope) { + if ($scope->getIdentifier() === 'openid') { + return true; + } + } + return false; + } +} diff --git a/src/Interfaces/IdentityEntityInterface.php b/src/Interfaces/IdentityEntityInterface.php new file mode 100644 index 0000000..393df6b --- /dev/null +++ b/src/Interfaces/IdentityEntityInterface.php @@ -0,0 +1,13 @@ +mergeConfigFrom( + __DIR__ . '/config/openid.php', + 'openid' + ); + } + + public function boot() + { + parent::boot(); + + $this->publishes([ + __DIR__ . '/config/openidconnect.php' => $this->app->configPath('openidconnect.php'), + ], ['openidconnect', 'openidconnect-config']); + + Passport\Passport::tokensCan(config('openid.passport.tokens_can')); + } + + public function makeAuthorizationServer(): AuthorizationServer + { + $cryptKey = $this->makeCryptKey('private'); + + $customClaimSets = config('openid.custom_claim_sets'); + + $claimSets = array_map(function ($claimSet, $name) { + return new ClaimSet($name, $claimSet); + }, $customClaimSets, array_keys($customClaimSets)); + + $responseType = new IdTokenResponse( + app(config('openid.repositories.identity')), + new ClaimExtractor(...$claimSets), + Configuration::forSymmetricSigner( + new Sha256(), + InMemory::file($cryptKey->getKeyPath()), + ), + ); + + return new AuthorizationServer( + app(ClientRepository::class), + app(AccessTokenRepository::class), + app(config('openid.repositories.scope')), + $cryptKey, + app(Encrypter::class)->getKey(), + $responseType, + ); + } +} diff --git a/src/Laravel/config/openidconnect.php b/src/Laravel/config/openidconnect.php new file mode 100644 index 0000000..5dea82c --- /dev/null +++ b/src/Laravel/config/openidconnect.php @@ -0,0 +1,30 @@ + [ + 'tokens_can' => [ + 'openid' => 'Enable OpenID Connect', + 'profile' => 'Information about your profile', + 'email' => 'Information about your email address', + 'phone' => 'Information about your phone numbers', + 'address' => 'Information about your address', + // 'login' => 'See your login information', + ], + ], + + 'custom_claim_sets' => [ + // 'login' => [ + // 'last-login', + // ], + ], + + 'repositories' => [ + 'identity' => IdentityRepository::class, + 'scope' => ScopeRepository::class, + ], +]; diff --git a/src/Repositories/AccessTokenRepository.php b/src/Repositories/AccessTokenRepository.php new file mode 100644 index 0000000..56cc2fd --- /dev/null +++ b/src/Repositories/AccessTokenRepository.php @@ -0,0 +1,38 @@ +setClient($clientEntity); + foreach ($scopes as $scope) { + $accessToken->addScope($scope); + } + $accessToken->setUserIdentifier((string) $userIdentifier); + + return $accessToken; + } + + public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity) + { + } + + public function revokeAccessToken($tokenId) + { + } + + public function isAccessTokenRevoked($tokenId) + { + return false; + } +} diff --git a/src/Repositories/AuthCodeRepository.php b/src/Repositories/AuthCodeRepository.php new file mode 100644 index 0000000..e15ce5a --- /dev/null +++ b/src/Repositories/AuthCodeRepository.php @@ -0,0 +1,30 @@ +setIdentifier('1'); + $client->setRedirectUri('http://example.com/callback'); + $client->setName('Example'); + return $client; + } + + public function validateClient($clientIdentifier, $clientSecret, $grantType) + { + return true; + } +} diff --git a/src/Repositories/IdentityRepository.php b/src/Repositories/IdentityRepository.php new file mode 100644 index 0000000..3c9c4a4 --- /dev/null +++ b/src/Repositories/IdentityRepository.php @@ -0,0 +1,26 @@ +setIdentifier($identifier); + return $identityEntity; + } +} diff --git a/src/Repositories/RefreshTokenRepository.php b/src/Repositories/RefreshTokenRepository.php new file mode 100644 index 0000000..b0b2798 --- /dev/null +++ b/src/Repositories/RefreshTokenRepository.php @@ -0,0 +1,30 @@ +getScopeEntityByIdentifier($scope->getIdentifier()); + }); + } + + public function getScopeEntityByIdentifier($identifier) + { + $scopes = [ + 'openid' => ['description' => 'Enable OpenID Connect'], + 'profile' => ['description' => 'Information about your profile'], + 'email' => ['description' => 'Information about your email address'], + 'phone' => ['description' => 'Information about your phone numbers'], + 'address' => ['description' => 'Information about your address'], + ]; + + if (array_key_exists($identifier, $scopes) === false) { + return; + } + + $scope = new ScopeEntity(); + $scope->setIdentifier($identifier); + return $scope; + } +} diff --git a/tests/Bootstrap.php b/tests/Bootstrap.php new file mode 100644 index 0000000..5fb6079 --- /dev/null +++ b/tests/Bootstrap.php @@ -0,0 +1,5 @@ +setPrivateKey($cryptKey ?? KeyFactory::cryptKey()); + $accessToken->setClient($client ?? ClientFactory::default()); + $accessToken->setUserIdentifier(Config::USER_ID); + $accessToken->setIdentifier('access_token_id'); + $accessToken->setExpiryDateTime( + (new DateTimeImmutable())->add(new DateInterval('PT1H')) + ); + + if ($scopes) { + array_walk($scopes, function (string $scope) use ($accessToken) { + $accessToken->addScope(ScopeFactory::default($scope)); + }); + } + + return $accessToken; + } + + public static function default(): AccessTokenEntityInterface + { + return (new static())->build(); + } + + public static function withOpenIdScope(): AccessTokenEntityInterface + { + return (new static())->build(null, ['openid']); + } + + public static function withCryptKey(CryptKey $cryptKey): AccessTokenEntityInterface + { + return (new static())->build($cryptKey); + } + + public static function withScopes(array $scopes): AccessTokenEntityInterface + { + return (new static())->build(null, $scopes); + } + + public static function withCryptKeyAndScopes(CryptKey $cryptKey, array $scopes): AccessTokenEntityInterface + { + return (new static())->build($cryptKey, $scopes); + } +} diff --git a/tests/Factories/ClientFactory.php b/tests/Factories/ClientFactory.php new file mode 100644 index 0000000..00e9055 --- /dev/null +++ b/tests/Factories/ClientFactory.php @@ -0,0 +1,33 @@ +setIdentifier($identifier ?? Config::CLIENT_ID); + $client->setName('a_third_party_client'); + $client->setRedirectUri('https://' . Config::HTTP_HOST . '/'); + $client->setConfidential(); + + return $client; + } + + public static function default(): ClientEntityInterface + { + return (new static())->build(); + } + + public static function withClient(string $identifier): ClientEntityInterface + { + return (new static())->build($identifier); + } +} diff --git a/tests/Factories/ConfigutationFactory.php b/tests/Factories/ConfigutationFactory.php new file mode 100644 index 0000000..b60bf57 --- /dev/null +++ b/tests/Factories/ConfigutationFactory.php @@ -0,0 +1,31 @@ +build(); + } + + public static function withSignerKey(Key $signerKey): Configuration + { + return (new static())->build($signerKey); + } +} diff --git a/tests/Factories/IdTokenResponseFactory.php b/tests/Factories/IdTokenResponseFactory.php new file mode 100644 index 0000000..106ad70 --- /dev/null +++ b/tests/Factories/IdTokenResponseFactory.php @@ -0,0 +1,41 @@ +build($identityRepository, $claimExtractor); + } + + public static function withConfig( + IdentityRepositoryInterface $identityRepository, + ClaimExtractor $claimExtractor, + Configuration $config + ): BearerTokenResponse { + return (new static())->build($identityRepository, $claimExtractor, $config); + } +} diff --git a/tests/Factories/KeyFactory.php b/tests/Factories/KeyFactory.php new file mode 100644 index 0000000..51ecd96 --- /dev/null +++ b/tests/Factories/KeyFactory.php @@ -0,0 +1,32 @@ +setPrivateKey($privateKey ?? KeyFactory::cryptKey()); + $response->setEncryptionKey(base64_encode(random_bytes(32))); + $response->setAccessToken($accessToken); + $response->setRefreshToken($refreshToken); + + return $response->generateHttpResponse(new Psr7\Response()); + } + + public static function default( + AccessTokenEntityInterface $accessToken, + RefreshTokenEntityInterface $refreshToken, + ): Psr7\Response { + return (new static())->build($accessToken, $refreshToken); + } + + public static function withIdTokenResponse( + AccessTokenEntityInterface $accessToken, + RefreshTokenEntityInterface $refreshToken, + IdTokenResponse $response + ): Psr7\Response { + return (new static())->build($accessToken, $refreshToken, $response); + } + + public static function withConfig( + AccessTokenEntityInterface $accessToken, + RefreshTokenEntityInterface $refreshToken, + Configuration $config, + ): Psr7\Response { + return (new static())->build( + $accessToken, + $refreshToken, + IdTokenResponseFactory::withConfig( + new IdentityRepository(), + new ClaimExtractor(), + $config + ) + ); + } +} diff --git a/tests/Factories/RefreshTokenFactory.php b/tests/Factories/RefreshTokenFactory.php new file mode 100644 index 0000000..423d084 --- /dev/null +++ b/tests/Factories/RefreshTokenFactory.php @@ -0,0 +1,38 @@ +setAccessToken($accessToken ?? AccessTokenFactory::default()); + $refreshToken->setIdentifier('refresh_token_id'); + $refreshToken->setExpiryDateTime( + (new DateTimeImmutable())->add(new DateInterval('PT1H')) + ); + + return $refreshToken; + } + + public static function default(): RefreshTokenEntityInterface + { + return (new static())->build(); + } + + public static function withAccessToken( + AccessTokenEntityInterface $accessToken + ): RefreshTokenEntityInterface { + return (new static())->build($accessToken); + } +} diff --git a/tests/Factories/ScopeFactory.php b/tests/Factories/ScopeFactory.php new file mode 100644 index 0000000..f65222f --- /dev/null +++ b/tests/Factories/ScopeFactory.php @@ -0,0 +1,23 @@ +setIdentifier($identifier); + return $scope; + } + + public static function default(string $identifier): ScopeEntityInterface + { + return (new static())->build($identifier); + } +} diff --git a/tests/Factories/UserFactory.php b/tests/Factories/UserFactory.php new file mode 100644 index 0000000..bf4d737 --- /dev/null +++ b/tests/Factories/UserFactory.php @@ -0,0 +1,33 @@ +setIdentifier($identifier); + $entity->setClaims($claims); + return $entity; + } + + public static function default(string $identifier): IdentityEntityInterface + { + return (new static())->build($identifier); + } + + public static function withClaims( + string $identifier, + array $claims, + ) { + return (new static())->build($identifier, $claims); + } +} diff --git a/tests/Feature/ClaimExtractorTest.php b/tests/Feature/ClaimExtractorTest.php new file mode 100644 index 0000000..eae31e4 --- /dev/null +++ b/tests/Feature/ClaimExtractorTest.php @@ -0,0 +1,86 @@ + ['name' => 'profile']; + yield 'email' => ['name' => 'email']; + yield 'address' => ['name' => 'address']; + yield 'phone' => ['name' => 'phone']; + } + + /** + * @dataProvider protected_claim_sets + */ + public function test_default_claim_sets_exist(string $name) + { + $extractor = new ClaimExtractor(); + $this->assertTrue($extractor->hasClaimSet($name)); + } + + /** + * @dataProvider protected_claim_sets + */ + public function test_cannot_override_protected_scope(string $name) + { + $this->expectException(ProtectedScopeException::class); + $this->expectExceptionMessage("The scope '{$name}' is a protected scope."); + new ClaimExtractor(new ClaimSet($name, ['custom_claim'])); + } + + /** + * @dataProvider protected_claim_sets + */ + public function test_can_get_scope_by_name(string $name) + { + $claimset = (new ClaimExtractor())->getClaimSet($name); + $this->assertEquals($claimset->getScope(), $name); + } + + public function test_can_set_and_extract_custom_claim_set() + { + $claimSet = new ClaimSet('custom_set', ['custom_claim']); + $extractor = new ClaimExtractor($claimSet); + $this->assertTrue($extractor->hasClaimSet('custom_set')); + + $result = $extractor->extract(['custom_set'], ['custom_claim' => 'value']); + $this->assertEquals($result['custom_claim'], 'value'); + } + + public function test_can_safely_get_uknown_claim_set() + { + $extractor = new ClaimExtractor(); + $this->assertNull($extractor->getClaimSet('unknown')); + } + + public function test_can_safely_extract_uknown_claim() + { + $extractor = new ClaimExtractor(); + $result = $extractor->extract(['custom_set'], ['uknown' => 'uknown']); + $this->assertEmpty($result); + } + + public function test_can_safely_extract_known_claim_set() + { + $extractor = new ClaimExtractor(); + $result = $extractor->extract(['profile'], ['name' => 'John Snow']); + $this->assertEquals($result['name'], 'John Snow'); + } + + public function test_can_safely_extract_invalid_claim_set() + { + $extractor = new ClaimExtractor(); + $result = $extractor->extract(['profile'], ['invalid' => 'invalid']); + $this->assertEmpty($result); + } +} diff --git a/tests/Feature/ConfigurationTest.php b/tests/Feature/ConfigurationTest.php new file mode 100644 index 0000000..feb01bd --- /dev/null +++ b/tests/Feature/ConfigurationTest.php @@ -0,0 +1,43 @@ +assertInstanceOf( + Configuration::class, + ConfigutationFactory::default(), + ); + } + + public function test_can_create_configurations_with_key_from_text() + { + $this->assertInstanceOf( + Configuration::class, + ConfigutationFactory::withSignerKey( + KeyFactory::signerKeyFromText('my_secret'), + ), + ); + } + + public function test_configuration_needs_correct_signer_key_path() + { + $this->expectException(FileCouldNotBeRead::class); + $this->assertInstanceOf( + Configuration::class, + ConfigutationFactory::withSignerKey( + KeyFactory::signerKeyFromFile('does/not/exist'), + ), + ); + } +} diff --git a/tests/Feature/IdTokenTest.php b/tests/Feature/IdTokenTest.php new file mode 100644 index 0000000..972598e --- /dev/null +++ b/tests/Feature/IdTokenTest.php @@ -0,0 +1,63 @@ +assertInstanceOf(IdTokenResponse::class, $idTokenResponse); + $this->assertInstanceOf(BearerTokenResponse::class, $idTokenResponse); + } + + public function test_can_create_id_token_responses_with_openid_claim_set() + { + $claimSet = new ClaimSet('custom', ['custom_claim']); + $idTokenResponse = IdTokenResponseFactory::default( + new IdentityRepository(), + new ClaimExtractor($claimSet), + ); + $this->assertInstanceOf(IdTokenResponse::class, $idTokenResponse); + $this->assertInstanceOf(BearerTokenResponse::class, $idTokenResponse); + } + + public function test_receive_id_token_with_open_id_scope() + { + $response = Psr7ResponseFactory::default( + $accessToken = AccessTokenFactory::withOpenIdScope(), + RefreshTokenFactory::withAccessToken($accessToken), + ); + $this->defaultResponseAsserts($response); + + $json = json_decode($response->getBody()->getContents()); + $this->defaultTokenAsserts($json); + + $this->assertObjectHasAttribute('id_token', $json); + } +} diff --git a/tests/Feature/OauthTest.php b/tests/Feature/OauthTest.php new file mode 100644 index 0000000..09843ba --- /dev/null +++ b/tests/Feature/OauthTest.php @@ -0,0 +1,30 @@ +defaultResponseAsserts($response); + + $json = json_decode($response->getBody()->getContents()); + $this->defaultTokenAsserts($json); + + $this->assertObjectNotHasAttribute('id_token', $json); + } +} diff --git a/tests/Feature/TokensTest.php b/tests/Feature/TokensTest.php new file mode 100644 index 0000000..72df6ae --- /dev/null +++ b/tests/Feature/TokensTest.php @@ -0,0 +1,112 @@ +defaultResponseAsserts($response); + + $json = json_decode($response->getBody()->getContents()); + $this->defaultTokenAsserts($json); + + $token = $config->parser()->parse($json->id_token); + $this->assertInstanceOf(Plain::class, $token); + + $isValid = $config->validator()->validate( + $token, + ...[ + new IssuedBy('https://' . Config::HTTP_HOST), + new PermittedFor(Config::CLIENT_ID), + new RelatedTo(Config::USER_ID), + new SignedWith( + $config->signer(), + KeyFactory::signerKeyFromFile(), + ), + ], + ); + + $this->assertTrue($isValid); + } + + public function test_id_token_with_email_scope_returns_email_claim() + { + $scopes = ['openid', 'email']; + + $response = Psr7ResponseFactory::withConfig( + $accessToken = AccessTokenFactory::withScopes($scopes), + RefreshTokenFactory::withAccessToken($accessToken), + $config = ConfigutationFactory::default(), + ); + $this->defaultResponseAsserts($response); + + $json = json_decode($response->getBody()->getContents()); + $this->defaultTokenAsserts($json); + + /** @var Plain $token */ + $token = $config->parser()->parse($json->id_token); + $this->assertSame( + 'jon.snow@dorne.com', + $token->claims()->get('email') + ); + } + + /** + * @group dev + */ + public function test_id_token_with_custom_scope_returns_custom_claim() + { + $scopes = ['openid', 'custom']; + + $response = Psr7ResponseFactory::withIdTokenResponse( + $accessToken = AccessTokenFactory::withScopes($scopes), + RefreshTokenFactory::withAccessToken($accessToken), + IdTokenResponseFactory::withConfig( + new IdentityRepository(), + new ClaimExtractor(new ClaimSet('custom', ['what_he_knows'])), + $config = ConfigutationFactory::default(), + ) + ); + $this->defaultResponseAsserts($response); + + $json = json_decode($response->getBody()->getContents()); + $this->defaultTokenAsserts($json); + + /** @var Plain $token */ + $token = $config->parser()->parse($json->id_token); + $this->assertSame('Nothing!', $token->claims()->get('what_he_knows')); + } +} diff --git a/tests/Feature/Traits/WithDefaultAsserts.php b/tests/Feature/Traits/WithDefaultAsserts.php new file mode 100644 index 0000000..f3e77f0 --- /dev/null +++ b/tests/Feature/Traits/WithDefaultAsserts.php @@ -0,0 +1,31 @@ +assertInstanceOf(Psr7\Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame( + 'application/json; charset=UTF-8', + $response->getHeader('content-type')[0] + ); + + $response->getBody()->rewind(); + } + + public function defaultTokenAsserts($json) + { + $this->assertSame('Bearer', $json->token_type); + $this->assertSame(3600, $json->expires_in); + $this->assertObjectHasAttribute('access_token', $json); + $this->assertObjectHasAttribute('refresh_token', $json); + } +} diff --git a/tests/Unit/IdentityEntityTest.php b/tests/Unit/IdentityEntityTest.php new file mode 100644 index 0000000..cba9429 --- /dev/null +++ b/tests/Unit/IdentityEntityTest.php @@ -0,0 +1,41 @@ +assertTrue(property_exists(new IdentityEntity(), 'claims')); + } + + public function test_identity_entity_has_get_claims_method() + { + $this->assertTrue(method_exists(new IdentityEntity(), 'getClaims')); + } + + public function test_identity_entity_has_set_claims_method() + { + $this->assertTrue(method_exists(new IdentityEntity(), 'setClaims')); + } + + public function test_identity_entity_has_identifier_property() + { + $this->assertTrue(property_exists(new IdentityEntity(), 'identifier')); + } + + public function test_identity_entity_has_get_identifier_method() + { + $this->assertTrue(method_exists(new IdentityEntity(), 'getIdentifier')); + } + + public function test_identity_entity_has_set_identifier_method() + { + $this->assertTrue(method_exists(new IdentityEntity(), 'setIdentifier')); + } +} diff --git a/tests/Unit/ScopeEntityTest.php b/tests/Unit/ScopeEntityTest.php new file mode 100644 index 0000000..b586389 --- /dev/null +++ b/tests/Unit/ScopeEntityTest.php @@ -0,0 +1,26 @@ +assertTrue(property_exists(new ScopeEntity(), 'identifier')); + } + + public function test_scope_entity_has_get_identifier_method() + { + $this->assertTrue(method_exists(new ScopeEntity(), 'getIdentifier')); + } + + public function test_scope_entity_set_identifier_method() + { + $this->assertTrue(method_exists(new ScopeEntity(), 'setIdentifier')); + } +}