├── CHANGELOG.md ├── LICENCE.txt ├── README.md ├── bin └── create_keys ├── composer.json └── src ├── ClaimExtractor.php ├── Claims ├── ClaimSet.php ├── ClaimSetInterface.php ├── Claimable.php ├── Scopable.php └── Traits │ ├── WithClaims.php │ └── WithScope.php ├── Entities ├── AccessTokenEntity.php ├── AuthCodeEntity.php ├── ClientEntity.php ├── IdentityEntity.php ├── RefreshTokenEntity.php └── ScopeEntity.php ├── Exceptions └── ProtectedScopeException.php ├── Grant └── AuthCodeGrant.php ├── IdTokenResponse.php ├── Interfaces ├── CurrentRequestServiceInterface.php ├── IdentityEntityInterface.php └── IdentityRepositoryInterface.php ├── Laravel ├── DiscoveryController.php ├── JwksController.php ├── LaravelCurrentRequestService.php ├── PassportServiceProvider.php ├── config │ └── openid.php └── routes │ └── web.php ├── Repositories ├── AccessTokenRepository.php ├── AuthCodeRepository.php ├── ClientRepository.php ├── IdentityRepository.php └── RefreshTokenRepository.php └── Services ├── CurrentRequestService.php └── IssuedByGetter.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.0 (2021/../..) 4 | * Initial version 5 | -------------------------------------------------------------------------------- /LICENCE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ron van der Heijden 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [![PHP 8.2](https://github.com/jeremy379/laravel-openid-connect/actions/workflows/php82.yml/badge.svg)](https://github.com/jeremy379/laravel-openid-connect/actions/workflows/php82.yml) 3 | 4 | # OpenID Connect for Laravel 5 | 6 | OpenID Connect support to the PHP League's OAuth2 Server. 7 | 8 | This is a fork of [ronvanderheijden/openid-connect](https://github.com/ronvanderheijden/openid-connect). 9 | 10 | It's made to support only Laravel and [Laravel Passport](https://laravel.com/docs/10.x/passport). 11 | 12 | ## Requirements 13 | 14 | * Requires PHP version `^8.2`. 15 | * [lcobucci/jwt](https://github.com/lcobucci/jwt) version `^4.0`. 16 | * [league/oauth2-server](https://github.com/thephpleague/oauth2-server) `^8.2`. 17 | * Laravel 10 to 12 18 | * Laravel Passport installed and configured 19 | 20 | ## Installation 21 | 22 | ```sh 23 | composer require jeremy379/laravel-openid-connect 24 | ``` 25 | 26 | Now when calling the `oauth/authorize` endpoint, provide the `openid` scope to get an `id_token`. 27 | Provide more scopes (e.g. `openid profile email`) to receive additional claims in the `id_token`. 28 | 29 | The id_token will be returned after the call to the `oauth/token` endpoint. 30 | 31 | ### Laravel 11 32 | 33 | On Laravel 11 you may need to register the package: https://github.com/jeremy379/laravel-openid-connect/issues/31 34 | 35 | ## Configuration 36 | 37 | ### 1.) Add the scope in your AuthServiceProvider in boot() method. 38 | 39 | ```php 40 | Passport::tokensCan(config('openid.passport.tokens_can')); 41 | ```` 42 | 43 | You may want to combine existing scope and oauth implementation with the open ID connect. 44 | 45 | ```php 46 | $scopes = array_merge($yourScope, config('openid.passport.tokens_can')); 47 | Passport::tokensCan($scopes); 48 | ```` 49 | 50 | ### 2.) create an entity 51 | Create an entity class in `app/Entities/` named `IdentityEntity` or `UserEntity`. This entity is used to collect the claims. 52 | 53 | You can customize the entity setup by using another IdentityRepository, this is customizable in the config file. 54 | 55 | ```php 56 | # app/Entities/IdentityEntity.php 57 | namespace App\Entities; 58 | 59 | use League\OAuth2\Server\Entities\Traits\EntityTrait; 60 | use OpenIDConnect\Claims\Traits\WithClaims; 61 | use OpenIDConnect\Interfaces\IdentityEntityInterface; 62 | 63 | class IdentityEntity implements IdentityEntityInterface 64 | { 65 | use EntityTrait; 66 | use WithClaims; 67 | 68 | /** 69 | * The user to collect the additional information for 70 | */ 71 | protected User $user; 72 | 73 | /** 74 | * The identity repository creates this entity and provides the user id 75 | * @param mixed $identifier 76 | */ 77 | public function setIdentifier($identifier): void 78 | { 79 | $this->identifier = $identifier; 80 | $this->user = User::findOrFail($identifier); 81 | } 82 | 83 | /** 84 | * When building the id_token, this entity's claims are collected 85 | */ 86 | public function getClaims(): array 87 | { 88 | return [ 89 | 'email' => $this->user->email, 90 | ]; 91 | } 92 | } 93 | ``` 94 | 95 | ### The id token is a JWT and the client should verify the signature. 96 | 97 | Here is an example to verify the signature with lcobucci/jwt 98 | 99 | ```php 100 | $config = Configuration::forSymmetricSigner( 101 | new \Lcobucci\JWT\Signer\Rsa\Sha256(), 102 | InMemory::file(base_path('oauth-public.key')) //This is the public key generate by passport. You need to share it. 103 | ); 104 | 105 | //Parse the token 106 | 107 | $token = $config->parser()->parse($idtoken); 108 | 109 | $signatureValid = $config->validator()->validate($token, new \Lcobucci\JWT\Validation\Constraint\SignedWith($config->signer(), $config->signingKey())); 110 | ``` 111 | 112 | ### Publishing the config 113 | In case you want to change the default scopes, add custom claim sets or change the repositories, you can publish the openid config using: 114 | ```sh 115 | php artisan vendor:publish --tag=openid 116 | ``` 117 | 118 | ### Using nonce 119 | 120 | When `nonce` is required, you need to pass it as a query parameter to `passport.authorizations.approve` during authorization step. 121 | 122 | Example based on default Passport's `authorize.blade.php`: 123 | ``` 124 |
125 | ``` 126 | 127 | ### Optional Configuration 128 | You can add any JWT Token Headers that you want to the `token_headers` array in your `openid` configuration file. 129 | 130 | This can be useful to define things like the [`kid`(Key ID)](https://datatracker.ietf.org/doc/html/rfc7517#section-4.5). The `kid` can be any string as long as it can uniquely identify the key you want to use in your [JWKS](https://datatracker.ietf.org/doc/html/rfc7517#section-5). This can be useful when changing or rolling keys. 131 | 132 | Example: 133 | 134 | ```php 135 | 'token_headers' => ['kid' => base64_encode('public-key-added-2023-01-01')] 136 | ``` 137 | 138 | Additionally, you can configure the JWKS url and some settings for discovery in the config file. 139 | 140 | _Note: If you define a `kid` header, it will be added to the JWK returned at the jwks_url (if `jwks` is enabled in the configuration)._ 141 | 142 | ## Support 143 | 144 | You can fill an issue in the github section dedicated for that. I'll try to maintain this fork. 145 | 146 | Are you actively using this package and wanna help too? Reach out to me, I'm looking for help to maintain this package. 147 | 148 | ## License 149 | OpenID Connect is open source and licensed under [the MIT licence](https://github.com/ronvanderheijden/openid-connect/blob/master/LICENSE.txt). 150 | -------------------------------------------------------------------------------- /bin/create_keys: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -eu 4 | 5 | if [ ! -f "$(pwd)/tmp/private.key" ]; then 6 | mkdir -m 700 -p tmp 7 | 8 | openssl genrsa -out tmp/private.key 2048 9 | openssl rsa -in tmp/private.key -pubout -out tmp/public.key 10 | 11 | chmod 600 tmp/private.key 12 | chmod 644 tmp/public.key 13 | fi 14 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jeremy379/laravel-openid-connect", 3 | "type": "library", 4 | "description": "OpenID Connect support to the PHP League's OAuth2 Server. Compatible with Laravel Passport.", 5 | "license": "MIT", 6 | "homepage": "https://github.com/jeremy379/laravel-openid-connect", 7 | "authors": [ 8 | { 9 | "name": "Jérémy Dillenbourg", 10 | "email": "jeremy@dillenbourg.be" 11 | }, 12 | { 13 | "name": "Ron van der Heijden", 14 | "email": "r.heijden@live.nl" 15 | } 16 | ], 17 | "keywords": [ 18 | "openid", 19 | "openid-connect", 20 | "oidc", 21 | "oauth2", 22 | "laravel", 23 | "passport" 24 | ], 25 | "require": { 26 | "php": ">=8.2", 27 | "lcobucci/jwt": "^4.0|^4.3|^5.0", 28 | "league/oauth2-server": "^8.2.0|^9.2.0", 29 | "laravel/passport": "^11.0|^12.0|^13.0", 30 | "laravel/framework": "^10.0|^11.0|^12.0", 31 | "ext-openssl": "*", 32 | "nyholm/psr7": "^1.8" 33 | }, 34 | "require-dev": { 35 | "guzzlehttp/psr7": "^1.7|^2.7.0", 36 | "http-interop/http-factory-guzzle": "^1.0", 37 | "overtrue/phplint": "^9.0", 38 | "phpunit/phpunit": "^10.5.0", 39 | "slevomat/coding-standard": "^6.4.1", 40 | "slim/slim": "4.*", 41 | "symplify/easy-coding-standard": "^9.2", 42 | "league/oauth2-client": "^2.6" 43 | }, 44 | "autoload": { 45 | "psr-4": { 46 | "OpenIDConnect\\": "src/" 47 | } 48 | }, 49 | "autoload-dev": { 50 | "psr-4": { 51 | "OpenIDConnect\\Example\\": "example/", 52 | "OpenIDConnect\\Tests\\": "tests/" 53 | } 54 | }, 55 | "extra": { 56 | "laravel": { 57 | "providers": [ 58 | "OpenIDConnect\\Laravel\\PassportServiceProvider" 59 | ] 60 | } 61 | }, 62 | "scripts": { 63 | "test": "vendor/bin/phpunit", 64 | "dev": "vendor/bin/phpunit --group dev", 65 | "ecs-check": "vendor/bin/ecs check", 66 | "ecs-fix": "vendor/bin/ecs check --fix", 67 | "lint": "vendor/bin/phplint --exclude=vendor .", 68 | "fix": [ 69 | "composer update", 70 | "composer ecs-fix", 71 | "composer check" 72 | ], 73 | "check": [ 74 | "composer lint", 75 | "composer ecs-check", 76 | "composer test", 77 | "composer check-platform-reqs", 78 | "composer outdated --direct --no-ansi", 79 | "composer outdated --minor-only --strict --direct" 80 | ] 81 | }, 82 | "config": { 83 | "allow-plugins": { 84 | "dealerdirect/phpcodesniffer-composer-installer": true, 85 | "php-http/discovery": false 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/ClaimExtractor.php: -------------------------------------------------------------------------------- 1 | addClaimSet($this->profile()) 25 | ->addClaimSet($this->email()) 26 | ->addClaimSet($this->address()) 27 | ->addClaimSet($this->phone()); 28 | 29 | foreach ($claimSets as $claimSet) { 30 | $this->addClaimSet($claimSet); 31 | } 32 | } 33 | 34 | /** 35 | * @return string[] 36 | */ 37 | public function getProtectedClaims(): array 38 | { 39 | return ['profile', 'email', 'address', 'phone']; 40 | } 41 | 42 | /** @throws ProtectedScopeException */ 43 | public function addClaimSet(ClaimSetInterface $claimSet): self 44 | { 45 | $scope = $claimSet->getScope(); 46 | 47 | if (in_array($scope, $this->getProtectedClaims()) && !empty($this->claimSets[$scope])) { 48 | throw new ProtectedScopeException($scope); 49 | } 50 | $this->claimSets[$scope] = $claimSet; 51 | 52 | return $this; 53 | } 54 | 55 | public function getClaimSet(string $scope): ?ClaimSetInterface 56 | { 57 | return $this->hasClaimSet($scope) ? $this->claimSets[$scope] : null; 58 | } 59 | 60 | public function hasClaimSet(string $scope): bool 61 | { 62 | return array_key_exists($scope, $this->claimSets); 63 | } 64 | 65 | /** 66 | * @param string[]|ScopeEntityInterface[] $scopes 67 | * @param string[] $claims 68 | * @return string[] 69 | */ 70 | public function extract(array $scopes, array $claims): array 71 | { 72 | $extracted = []; 73 | foreach ($scopes as $scope) { 74 | if ($scope instanceof ScopeEntityInterface) { 75 | $scope = $scope->getIdentifier(); 76 | } 77 | 78 | if (!$claimSet = $this->getClaimSet($scope)) { 79 | continue; 80 | } 81 | 82 | $intersected = array_intersect($claimSet->getClaims(), array_keys($claims)); 83 | 84 | $extracted = array_merge( 85 | $extracted, 86 | array_filter($claims, function ($key) use ($intersected) { 87 | return in_array($key, $intersected); 88 | }, ARRAY_FILTER_USE_KEY) 89 | ); 90 | } 91 | return $extracted; 92 | } 93 | 94 | private function profile(): ClaimSetInterface 95 | { 96 | return new ClaimSet('profile', [ 97 | 'name', 98 | 'family_name', 99 | 'given_name', 100 | 'middle_name', 101 | 'nickname', 102 | 'preferred_username', 103 | 'profile', 104 | 'picture', 105 | 'website', 106 | 'gender', 107 | 'birthdate', 108 | 'zoneinfo', 109 | 'locale', 110 | 'updated_at', 111 | ]); 112 | } 113 | 114 | private function email(): ClaimSetInterface 115 | { 116 | return new ClaimSet('email', [ 117 | 'email', 118 | 'email_verified', 119 | ]); 120 | } 121 | 122 | private function address(): ClaimSetInterface 123 | { 124 | return new ClaimSet('address', [ 125 | 'address', 126 | ]); 127 | } 128 | 129 | private function phone(): ClaimSetInterface 130 | { 131 | return new ClaimSet('phone', [ 132 | 'phone_number', 133 | 'phone_number_verified', 134 | ]); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Claims/ClaimSet.php: -------------------------------------------------------------------------------- 1 | scope = $scope; 16 | $this->claims = $claims; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Claims/ClaimSetInterface.php: -------------------------------------------------------------------------------- 1 | claims; 20 | } 21 | 22 | /** 23 | * @param string[] $claims 24 | */ 25 | public function setClaims(array $claims): void 26 | { 27 | $this->claims = $claims; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Claims/Traits/WithScope.php: -------------------------------------------------------------------------------- 1 | scope; 12 | } 13 | 14 | public function setScope(string $scope): void 15 | { 16 | $this->scope = $scope; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Entities/AccessTokenEntity.php: -------------------------------------------------------------------------------- 1 | name = $name; 17 | } 18 | 19 | public function setRedirectUri($uri) 20 | { 21 | $this->redirectUri = $uri; 22 | } 23 | 24 | public function setConfidential() 25 | { 26 | $this->isConfidential = true; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Entities/IdentityEntity.php: -------------------------------------------------------------------------------- 1 | 'Jon Snow', 28 | 'nickname' => 'The Bastard of Winterfell', 29 | 30 | // email 31 | 'email' => 'jon.snow@dorne.com', 32 | 'email_verified' => true, 33 | 34 | // phone 35 | 'phone_number' => '0031 493 123 456', 36 | 'phone_number_verified' => true, 37 | 38 | // address 39 | 'address' => 'Castle Black, The Night\'s Watch, The North', 40 | 41 | // custom 42 | 'what_he_knows' => 'Nothing!', 43 | ]; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Entities/RefreshTokenEntity.php: -------------------------------------------------------------------------------- 1 | psr7Response = $psr7Response; 41 | $this->currentRequestService = $currentRequestService; 42 | } 43 | 44 | /** 45 | * {@inheritdoc} 46 | */ 47 | public function completeAuthorizationRequest(AuthorizationRequestInterface $authorizationRequest): ResponseTypeInterface 48 | { 49 | // See https://github.com/steverhoades/oauth2-openid-connect-server/issues/47#issuecomment-1228370632 50 | 51 | /** @var RedirectResponse $response */ 52 | $response = parent::completeAuthorizationRequest($authorizationRequest); 53 | 54 | $queryParams = $this->currentRequestService->getRequest()->getQueryParams(); 55 | 56 | if (isset($queryParams['nonce'])) { 57 | // The only way to get the redirect URI is to generate the PSR7 response 58 | // (The RedirectResponse class does not have a getter for the redirect URI) 59 | $httpResponse = $response->generateHttpResponse($this->psr7Response); 60 | $redirectUri = $httpResponse->getHeader('Location')[0]; 61 | $parsed = parse_url($redirectUri); 62 | 63 | parse_str($parsed['query'], $query); 64 | 65 | $authCodePayload = json_decode($this->decrypt($query['code']), true, 512, JSON_THROW_ON_ERROR); 66 | 67 | $authCodePayload['nonce'] = $queryParams['nonce']; 68 | 69 | $query['code'] = $this->encrypt(json_encode($authCodePayload, JSON_THROW_ON_ERROR)); 70 | 71 | $parsed['query'] = http_build_query($query); 72 | 73 | $response->setRedirectUri($this->unparse_url($parsed)); 74 | } 75 | 76 | return $response; 77 | } 78 | 79 | /** 80 | * Inverse of parse_url 81 | * 82 | * @param mixed $parsed_url 83 | * @return string 84 | */ 85 | private function unparse_url($parsed_url) 86 | { 87 | $scheme = isset($parsed_url['scheme']) ? $parsed_url['scheme'] . '://' : ''; 88 | $host = isset($parsed_url['host']) ? $parsed_url['host'] : ''; 89 | $port = isset($parsed_url['port']) ? ':' . $parsed_url['port'] : ''; 90 | $user = isset($parsed_url['user']) ? $parsed_url['user'] : ''; 91 | $pass = isset($parsed_url['pass']) ? ':' . $parsed_url['pass'] : ''; 92 | $pass = ($user || $pass) ? "$pass@" : ''; 93 | $path = isset($parsed_url['path']) ? $parsed_url['path'] : ''; 94 | $query = isset($parsed_url['query']) ? '?' . $parsed_url['query'] : ''; 95 | $fragment = isset($parsed_url['fragment']) ? '#' . $parsed_url['fragment'] : ''; 96 | return "$scheme$user$pass$host$port$path$query$fragment"; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/IdTokenResponse.php: -------------------------------------------------------------------------------- 1 | identityRepository = $identityRepository; 39 | $this->claimExtractor = $claimExtractor; 40 | $this->config = $config; 41 | $this->tokenHeaders = $tokenHeaders; 42 | $this->useMicroseconds = $useMicroseconds; 43 | $this->currentRequestService = $currentRequestService; 44 | $this->encryptionKey = $encryptionKey; 45 | } 46 | 47 | protected function getBuilder( 48 | AccessTokenEntityInterface $accessToken, 49 | IdentityEntityInterface $userEntity 50 | ): Builder { 51 | $dateTimeImmutableObject = DateTimeImmutable::createFromFormat( 52 | ($this->useMicroseconds ? 'U.u' : 'U'), 53 | ($this->useMicroseconds ? microtime(true) : time()) 54 | ); 55 | 56 | return $this->config 57 | ->builder() 58 | ->permittedFor($accessToken->getClient()->getIdentifier()) 59 | ->issuedBy(IssuedByGetter::get($this->currentRequestService, $this->issuedByConfigured)) 60 | ->issuedAt($dateTimeImmutableObject) 61 | ->expiresAt($dateTimeImmutableObject->add(new DateInterval('PT1H'))) 62 | ->relatedTo($userEntity->getIdentifier()); 63 | } 64 | 65 | protected function getExtraParams(AccessTokenEntityInterface $accessToken): array { 66 | /** 67 | * Include the scope return value, which according to RFC 6749, section 5.1 (and 3.3) 68 | * is also required if the scope doesn't match the requested scope, which it might, and is optional otherwise. 69 | * 70 | * The value of the scope parameter is expressed as a list of space-delimited, case-sensitive strings. 71 | */ 72 | $scopes = $accessToken->getScopes(); 73 | 74 | $params = [ 75 | 'scope' => implode(' ', array_map(function ($value) { 76 | return $value->getIdentifier(); 77 | }, $scopes)), 78 | ]; 79 | 80 | if (!$this->hasOpenIDScope(...$scopes)) { 81 | return $params; 82 | } 83 | 84 | $user = $this->identityRepository->getByIdentifier( 85 | (string) $accessToken->getUserIdentifier(), 86 | ); 87 | 88 | $builder = $this->getBuilder($accessToken, $user); 89 | 90 | foreach ($this->tokenHeaders as $key => $value) { 91 | $builder = $builder->withHeader($key, $value); 92 | } 93 | 94 | if ($this->currentRequestService) { 95 | // If the request contains a code, we look into the code to find the nonce. 96 | $body = $this->currentRequestService->getRequest()->getParsedBody(); 97 | if (isset($body['code'])) { 98 | $authCodePayload = json_decode($this->decrypt($body['code']), true, 512, JSON_THROW_ON_ERROR); 99 | if (isset($authCodePayload['nonce'])) { 100 | $builder = $builder->withClaim('nonce', $authCodePayload['nonce']); 101 | } 102 | } 103 | } 104 | 105 | $claims = $this->claimExtractor->extract( 106 | $scopes, 107 | $user->getClaims(explode(' ', $params['scope'])), 108 | ); 109 | 110 | foreach ($claims as $claimName => $claimValue) { 111 | $builder = $builder->withClaim($claimName, $claimValue); 112 | } 113 | 114 | $token = $builder->getToken( 115 | $this->config->signer(), 116 | $this->config->signingKey(), 117 | ); 118 | 119 | return array_merge($params, ['id_token' => $token->toString()]); 120 | } 121 | 122 | private function hasOpenIDScope(ScopeEntityInterface ...$scopes): bool { 123 | foreach ($scopes as $scope) { 124 | if ($scope->getIdentifier() === 'openid') { 125 | return true; 126 | } 127 | } 128 | return false; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Interfaces/CurrentRequestServiceInterface.php: -------------------------------------------------------------------------------- 1 | IssuedByGetter::get($currentRequestService, config('openid.issuedBy', 'laravel')), 22 | 'authorization_endpoint' => route('passport.authorizations.authorize'), 23 | 'token_endpoint' => route('passport.token'), 24 | 'grant_types_supported' => $this->getSupportedGrantTypes(), 25 | 'response_types_supported' => $this->getSupportedResponseTypes(), 26 | 'subject_types_supported' => [ 27 | 'public', 28 | ], 29 | 'id_token_signing_alg_values_supported' => [ 30 | 'RS256', 31 | ], 32 | 'scopes_supported' => $this->getSupportedScopes(), 33 | 'token_endpoint_auth_methods_supported' => [ 34 | 'client_secret_basic', 35 | 'client_secret_post', 36 | ], 37 | ]; 38 | 39 | if (Route::has('openid.userinfo')) { 40 | $response['userinfo_endpoint'] = route('openid.userinfo'); 41 | } 42 | 43 | if (Route::has('openid.jwks')) { 44 | $response['jwks_uri'] = route('openid.jwks'); 45 | } 46 | 47 | if (Route::has('openid.end_session_endpoint')) { 48 | $response['end_session_endpoint'] = route('openid.end_session_endpoint'); 49 | } 50 | 51 | return response()->json($response, 200, [], JSON_PRETTY_PRINT); 52 | } 53 | 54 | /** 55 | * Returns JSON array containing a list of the OAuth 2.0 [RFC6749] scope values that this server supports. 56 | * The server MUST support the openid scope value. 57 | * Servers MAY choose not to advertise some supported scope values even when this parameter is used, 58 | * although those defined in [OpenID.Core] SHOULD be listed, if supported. 59 | */ 60 | private function getSupportedScopes(): array { 61 | $scopes = array_keys(config('openid.passport.tokens_can')); 62 | 63 | if (!config('openid.discovery.hide_scopes', false)) { 64 | return $scopes; 65 | } 66 | 67 | /** 68 | * Otherwise, only return scopes from the OpenID Core Spec, section 5.4 69 | */ 70 | return array_intersect($scopes, [ 71 | 'openid', 72 | 'profile', 73 | 'email', 74 | 'address', 75 | 'phone', 76 | ]); 77 | } 78 | 79 | private function getSupportedGrantTypes(): array { 80 | // See PassportServiceProvider for grant types that cannot be disabled 81 | $grants = [ 82 | 'authorization_code', // Cannot be disabled in Passport 83 | 'client_credentials', // Cannot be disabled in Passport 84 | 'refresh_token', // Cannot be disabled in Passport 85 | ]; 86 | 87 | if (Passport::$implicitGrantEnabled) { 88 | $grants[] = "implicit"; 89 | } 90 | 91 | if (Passport::$passwordGrantEnabled) { 92 | $grants[] = "password"; 93 | } 94 | 95 | return $grants; 96 | } 97 | 98 | /** 99 | * Returns JSON array containing a list of the OAuth 2.0 response_type values that this OP supports. 100 | * Dynamic OpenID Providers MUST support the code, id_token, and the id_token token Response Type values. 101 | */ 102 | private function getSupportedResponseTypes(): array { 103 | /** 104 | * These are always possible with Auth Code Grant 105 | */ 106 | $response_types = [ 107 | 'code', 108 | ]; 109 | 110 | if (Passport::$implicitGrantEnabled) { 111 | /** 112 | * Return all variants, indicating both Auth Code & implicit are allowed 113 | */ 114 | return array_merge($response_types, [ 115 | 'token', 116 | /** 117 | * TODO: Allow `id_token`, `id_token token`, `code id_token`, `code token`, `code id_token token` 118 | * if we build the Implict Flow path. 119 | * See https://github.com/jeremy379/laravel-openid-connect/issues/6 120 | */ 121 | ]); 122 | } 123 | 124 | return $response_types; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Laravel/JwksController.php: -------------------------------------------------------------------------------- 1 | getPublicKey(); 11 | 12 | // Source: https://www.tuxed.net/fkooman/blog/json_web_key_set.html 13 | $keyInfo = openssl_pkey_get_details(openssl_pkey_get_public($publicKey)); 14 | 15 | $passportJWK = [ 16 | 'alg' => 'RS256', 17 | 'kty' => 'RSA', 18 | 'use' => 'sig', 19 | 'n' => rtrim(str_replace(['+', '/'], ['-', '_'], base64_encode($keyInfo['rsa']['n'])), '='), 20 | 'e' => rtrim(str_replace(['+', '/'], ['-', '_'], base64_encode($keyInfo['rsa']['e'])), '='), 21 | ]; 22 | 23 | // Adds the kid if it is set in the config's token_headers 24 | if ($kid = config('openid.token_headers.kid', false)) { 25 | $passportJWK['kid'] = $kid; 26 | } 27 | 28 | $jsonData = [ 29 | 'keys' => [ 30 | $passportJWK, 31 | ], 32 | ]; 33 | 34 | return response()->json($jsonData, 200, [], JSON_PRETTY_PRINT); 35 | } 36 | 37 | private function getPublicKey(): string { 38 | $publicKey = str_replace('\\n', "\n", config('passport.public_key', '')); 39 | 40 | if (!$publicKey) { 41 | $publicKey = 'file://'.Passport::keyPath('oauth-public.key'); 42 | } 43 | 44 | return $publicKey; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Laravel/LaravelCurrentRequestService.php: -------------------------------------------------------------------------------- 1 | createRequest(request()); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Laravel/PassportServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom( 26 | __DIR__ . '/config/openid.php', 27 | 'openid' 28 | ); 29 | } 30 | 31 | public function boot(): void 32 | { 33 | parent::boot(); 34 | 35 | $this->publishes([ 36 | __DIR__ . '/config/openid.php' => $this->app->configPath('openid.php'), 37 | ], ['openid', 'openid-config']); 38 | 39 | $this->loadRoutesFrom(__DIR__."/routes/web.php"); 40 | 41 | $this->registerClaimExtractor(); 42 | } 43 | 44 | public function makeAuthorizationServer(?ResponseTypeInterface $responseType = null): AuthorizationServer 45 | { 46 | $cryptKey = $this->makeCryptKey('private'); 47 | $encryptionKey = $this->getEncryptionKey(app(Encrypter::class)->getKey()); 48 | 49 | $customClaimSets = config('openid.custom_claim_sets'); 50 | 51 | $claimSets = array_map(function ($claimSet, $name) { 52 | return new ClaimSet($name, $claimSet); 53 | }, $customClaimSets, array_keys($customClaimSets)); 54 | 55 | $responseType = new IdTokenResponse( 56 | app(config('openid.repositories.identity')), 57 | new ClaimExtractor(...$claimSets), 58 | Configuration::forSymmetricSigner( 59 | app(config('openid.signer')), 60 | InMemory::plainText($cryptKey->getKeyContents(), $cryptKey->getPassPhrase() ?? '') 61 | ), 62 | config('openid.token_headers'), 63 | config('openid.use_microseconds'), 64 | app(LaravelCurrentRequestService::class), 65 | $encryptionKey, 66 | config('openid.issuedBy', 'laravel') 67 | ); 68 | 69 | return new AuthorizationServer( 70 | app(ClientRepository::class), 71 | app(AccessTokenRepository::class), 72 | app(Passport\Bridge\ScopeRepository::class), 73 | $cryptKey, 74 | $encryptionKey, 75 | $responseType, 76 | ); 77 | } 78 | 79 | /** 80 | * Build the Auth Code grant instance. 81 | * 82 | * @return AuthCodeGrant 83 | */ 84 | protected function buildAuthCodeGrant(): AuthCodeGrant 85 | { 86 | return new AuthCodeGrant( 87 | $this->app->make(Passport\Bridge\AuthCodeRepository::class), 88 | $this->app->make(Passport\Bridge\RefreshTokenRepository::class), 89 | new \DateInterval('PT10M'), 90 | new Response(), 91 | $this->app->make(LaravelCurrentRequestService::class), 92 | ); 93 | } 94 | 95 | /** 96 | * Get encryption key as string or a Key instance 97 | * 98 | * Based on https://github.com/laravel/passport/pull/820 99 | * 100 | * @param string $keyBytes Encryption key as string 101 | * 102 | * @return \Defuse\Crypto\Key|string 103 | */ 104 | protected function getEncryptionKey($keyBytes) 105 | { 106 | // For BC reasons we return string, but implementations can override this method to return an object. 107 | // As mentioned in https://github.com/laravel/passport/pull/820 it gives better performance. 108 | return $keyBytes; 109 | } 110 | 111 | public function registerClaimExtractor() { 112 | $this->app->singleton(ClaimExtractor::class, function () { 113 | $customClaimSets = config('openid.custom_claim_sets'); 114 | 115 | $claimSets = array_map(function ($claimSet, $name) { 116 | return new ClaimSet($name, $claimSet); 117 | }, $customClaimSets, array_keys($customClaimSets)); 118 | 119 | return new ClaimExtractor(...$claimSets); 120 | }); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Laravel/config/openid.php: -------------------------------------------------------------------------------- 1 | [ 5 | 6 | /** 7 | * Place your Passport and OpenID Connect scopes here. 8 | * To receive an `id_token, you should at least provide the openid scope. 9 | */ 10 | 'tokens_can' => [ 11 | 'openid' => 'Enable OpenID Connect', 12 | 'profile' => 'Information about your profile', 13 | 'email' => 'Information about your email address', 14 | 'phone' => 'Information about your phone numbers', 15 | 'address' => 'Information about your address', 16 | // 'login' => 'See your login information', 17 | ], 18 | ], 19 | 20 | /** 21 | * Place your custom claim sets here. 22 | */ 23 | 'custom_claim_sets' => [ 24 | // 'login' => [ 25 | // 'last-login', 26 | // ], 27 | // 'company' => [ 28 | // 'company_name', 29 | // 'company_address', 30 | // 'company_phone', 31 | // 'company_email', 32 | // ], 33 | ], 34 | 35 | /** 36 | * You can override the repositories below. 37 | */ 38 | 'repositories' => [ 39 | 'identity' => \OpenIDConnect\Repositories\IdentityRepository::class, 40 | ], 41 | 42 | 'routes' => [ 43 | /** 44 | * When set to true, this package will expose the OpenID Connect Discovery endpoint. 45 | * - /.well-known/openid-configuration 46 | */ 47 | 'discovery' => true, 48 | /** 49 | * When set to true, this package will expose the JSON Web Key Set endpoint. 50 | */ 51 | 'jwks' => true, 52 | /** 53 | * Optional URL to change the JWKS path to align with your custom Passport routes. 54 | * Defaults to /oauth/jwks 55 | */ 56 | 'jwks_url' => '/oauth/jwks', 57 | ], 58 | 59 | /** 60 | * Settings for the discovery endpoint 61 | */ 62 | 'discovery' => [ 63 | /** 64 | * Hide scopes that aren't from the OpenID Core spec from the Discovery, 65 | * default = false (all scopes are listed) 66 | */ 67 | 'hide_scopes' => false, 68 | ], 69 | 70 | /** 71 | * The signer to be used 72 | */ 73 | 'signer' => \Lcobucci\JWT\Signer\Rsa\Sha256::class, 74 | 75 | /** 76 | * Optional associative array that will be used to set headers on the JWT 77 | */ 78 | 'token_headers' => [], 79 | 80 | /** 81 | * By default, microseconds are included. 82 | */ 83 | 'use_microseconds' => true, 84 | 85 | /** 86 | * Value for the issuedBy params. By default: laravel to get the scheme and host from the $_SERVER variable. 87 | * Options: laravel (use Request to extract scheme and host), server (use $_SERVER to detect) 88 | * or another string that will be used as-is 89 | */ 90 | 'issuedBy' => 'laravel', 91 | ]; 92 | -------------------------------------------------------------------------------- /src/Laravel/routes/web.php: -------------------------------------------------------------------------------- 1 | name('openid.jwks'); 9 | } 10 | if (config('openid.routes.discovery', true)) { 11 | Route::get('/.well-known/openid-configuration', DiscoveryController::class)->name('openid.discovery'); 12 | } 13 | -------------------------------------------------------------------------------- /src/Repositories/AccessTokenRepository.php: -------------------------------------------------------------------------------- 1 | setClient($clientEntity); 16 | foreach ($scopes as $scope) { 17 | $accessToken->addScope($scope); 18 | } 19 | $accessToken->setUserIdentifier((string) $userIdentifier); 20 | 21 | return $accessToken; 22 | } 23 | 24 | public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity) 25 | { 26 | } 27 | 28 | public function revokeAccessToken($tokenId) 29 | { 30 | } 31 | 32 | public function isAccessTokenRevoked($tokenId) 33 | { 34 | return false; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Repositories/AuthCodeRepository.php: -------------------------------------------------------------------------------- 1 | setIdentifier('1'); 14 | $client->setRedirectUri('http://example.com/callback'); 15 | $client->setName('Example'); 16 | return $client; 17 | } 18 | 19 | public function validateClient($clientIdentifier, $clientSecret, $grantType) 20 | { 21 | return true; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Repositories/IdentityRepository.php: -------------------------------------------------------------------------------- 1 | setIdentifier($identifier); 25 | return $identityEntity; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Repositories/RefreshTokenRepository.php: -------------------------------------------------------------------------------- 1 | request === null) { 15 | throw new \RuntimeException('Request not set in CurrentRequestService'); 16 | } 17 | return $this->request; 18 | } 19 | 20 | public function setRequest(ServerRequestInterface $request): void 21 | { 22 | $this->request = $request; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Services/IssuedByGetter.php: -------------------------------------------------------------------------------- 1 | getRequest()->getUri(); 13 | return $uri->getScheme() . '://' . $uri->getHost() . ($uri->getPort() ? ':' . $uri->getPort() : ''); 14 | } 15 | 16 | if($issuedByConfigured === 'server' || ($issuedByConfigured === 'laravel' && !$currentRequestService)) { 17 | $host = $_SERVER['HTTP_HOST'] ?? null; 18 | 19 | $scheme = $_SERVER['REQUEST_SCHEME'] ?? null; 20 | 21 | if (empty($scheme)) { 22 | $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; 23 | } 24 | 25 | return $scheme . '://' . $host; 26 | } 27 | 28 | return $issuedByConfigured; 29 | } 30 | } 31 | --------------------------------------------------------------------------------