From b4a6198a229ea7aeef093321abdd85152bee9955 Mon Sep 17 00:00:00 2001 From: Yorick Bosman Date: Wed, 24 Apr 2024 22:21:31 +0200 Subject: [PATCH 1/2] Adding support for a nonce --- src/Grant/AuthCodeGrant.php | 91 +++++++++++++++++++ src/IdTokenResponse.php | 40 ++++++-- .../CurrentRequestServiceInterface.php | 19 ++++ src/Laravel/LaravelCurrentRequestService.php | 22 +++++ src/Laravel/PassportServiceProvider.php | 39 +++++++- src/Services/CurrentRequestService.php | 24 +++++ 6 files changed, 225 insertions(+), 10 deletions(-) create mode 100644 src/Grant/AuthCodeGrant.php create mode 100644 src/Interfaces/CurrentRequestServiceInterface.php create mode 100644 src/Laravel/LaravelCurrentRequestService.php create mode 100644 src/Services/CurrentRequestService.php diff --git a/src/Grant/AuthCodeGrant.php b/src/Grant/AuthCodeGrant.php new file mode 100644 index 0000000..b8c029a --- /dev/null +++ b/src/Grant/AuthCodeGrant.php @@ -0,0 +1,91 @@ +psr7Response = $psr7Response; + $this->currentRequestService = $currentRequestService; + } + + /** + * {@inheritdoc} + */ + public function completeAuthorizationRequest(AuthorizationRequest $authorizationRequest) + { + // See https://github.com/steverhoades/oauth2-openid-connect-server/issues/47#issuecomment-1228370632 + + /** @var RedirectResponse $response */ + $response = parent::completeAuthorizationRequest($authorizationRequest); + + $queryParams = $this->currentRequestService->getRequest()->getQueryParams(); + + if (isset($queryParams['nonce'])) { + // The only way to get the redirect URI is to generate the PSR7 response (The RedirectResponse class does not have a getter for the redirect URI) + $httpResponse = $response->generateHttpResponse($this->psr7Response); + $redirectUri = $httpResponse->getHeader('Location')[0]; + $parsed = parse_url($redirectUri); + + parse_str($parsed['query'], $query); + + $authCodePayload = json_decode($this->decrypt($query['code']), true, 512, JSON_THROW_ON_ERROR); + + $authCodePayload['nonce'] = $queryParams['nonce']; + + $query['code'] = $this->encrypt(json_encode($authCodePayload, JSON_THROW_ON_ERROR)); + + $parsed['query'] = http_build_query($query); + + $response->setRedirectUri($this->unparse_url($parsed)); + } + + return $response; + } + + /** + * Inverse of parse_url + * + * @param mixed $parsed_url + * @return string + */ + private function unparse_url($parsed_url) + { + $scheme = isset($parsed_url['scheme']) ? $parsed_url['scheme'] . '://' : ''; + $host = isset($parsed_url['host']) ? $parsed_url['host'] : ''; + $port = isset($parsed_url['port']) ? ':' . $parsed_url['port'] : ''; + $user = isset($parsed_url['user']) ? $parsed_url['user'] : ''; + $pass = isset($parsed_url['pass']) ? ':' . $parsed_url['pass'] : ''; + $pass = ($user || $pass) ? "$pass@" : ''; + $path = isset($parsed_url['path']) ? $parsed_url['path'] : ''; + $query = isset($parsed_url['query']) ? '?' . $parsed_url['query'] : ''; + $fragment = isset($parsed_url['fragment']) ? '#' . $parsed_url['fragment'] : ''; + return "$scheme$user$pass$host$port$path$query$fragment"; + } +} diff --git a/src/IdTokenResponse.php b/src/IdTokenResponse.php index 90285b0..178be5c 100644 --- a/src/IdTokenResponse.php +++ b/src/IdTokenResponse.php @@ -6,19 +6,23 @@ use DateTimeImmutable; use Lcobucci\JWT\Builder; use Lcobucci\JWT\Configuration; +use League\OAuth2\Server\CryptTrait; use League\OAuth2\Server\Entities\AccessTokenEntityInterface; use League\OAuth2\Server\Entities\ScopeEntityInterface; use League\OAuth2\Server\ResponseTypes\BearerTokenResponse; +use OpenIDConnect\Interfaces\CurrentRequestServiceInterface; use OpenIDConnect\Interfaces\IdentityEntityInterface; use OpenIDConnect\Interfaces\IdentityRepositoryInterface; -class IdTokenResponse extends BearerTokenResponse -{ +class IdTokenResponse extends BearerTokenResponse { + use CryptTrait; + protected IdentityRepositoryInterface $identityRepository; protected ClaimExtractor $claimExtractor; private Configuration $config; + private ?CurrentRequestServiceInterface $currentRequestService; private array $tokenHeaders; @@ -29,13 +33,17 @@ public function __construct( ClaimExtractor $claimExtractor, Configuration $config, array $tokenHeaders = [], - bool $useMicroseconds = true + bool $useMicroseconds = true, + CurrentRequestServiceInterface $currentRequestService = null, + $encryptionKey = null, ) { $this->identityRepository = $identityRepository; $this->claimExtractor = $claimExtractor; $this->config = $config; $this->tokenHeaders = $tokenHeaders; $this->useMicroseconds = $useMicroseconds; + $this->currentRequestService = $currentRequestService; + $this->encryptionKey = $encryptionKey; } protected function getBuilder( @@ -47,17 +55,23 @@ protected function getBuilder( ($this->useMicroseconds ? microtime(true) : time()) ); + if ($this->currentRequestService) { + $uri = $this->currentRequestService->getRequest()->getUri(); + $issuer = $uri->getScheme() . '://' . $uri->getHost() . ($uri->getPort() ? ':' . $uri->getPort() : ''); + } else { + $issuer = 'https://' . $_SERVER['HTTP_HOST']; + } + return $this->config ->builder() ->permittedFor($accessToken->getClient()->getIdentifier()) - ->issuedBy('https://' . $_SERVER['HTTP_HOST']) + ->issuedBy($issuer) ->issuedAt($dateTimeImmutableObject) ->expiresAt($dateTimeImmutableObject->add(new DateInterval('PT1H'))) ->relatedTo($userEntity->getIdentifier()); } - protected function getExtraParams(AccessTokenEntityInterface $accessToken): array - { + protected function getExtraParams(AccessTokenEntityInterface $accessToken): array { if (!$this->hasOpenIDScope(...$accessToken->getScopes())) { return []; } @@ -72,6 +86,17 @@ protected function getExtraParams(AccessTokenEntityInterface $accessToken): arra $builder = $builder->withHeader($key, $value); } + if ($this->currentRequestService) { + // If the request contains a code, we look into the code to find the nonce. + $body = $this->currentRequestService->getRequest()->getParsedBody(); + if (isset($body['code'])) { + $authCodePayload = json_decode($this->decrypt($body['code']), true, 512, JSON_THROW_ON_ERROR); + if (isset($authCodePayload['nonce'])) { + $builder = $builder->withClaim('nonce', $authCodePayload['nonce']); + } + } + } + $claims = $this->claimExtractor->extract( $accessToken->getScopes(), $user->getClaims(), @@ -89,8 +114,7 @@ protected function getExtraParams(AccessTokenEntityInterface $accessToken): arra return ['id_token' => $token->toString()]; } - private function hasOpenIDScope(ScopeEntityInterface ...$scopes): bool - { + private function hasOpenIDScope(ScopeEntityInterface ...$scopes): bool { foreach ($scopes as $scope) { if ($scope->getIdentifier() === 'openid') { return true; diff --git a/src/Interfaces/CurrentRequestServiceInterface.php b/src/Interfaces/CurrentRequestServiceInterface.php new file mode 100644 index 0000000..d57ae59 --- /dev/null +++ b/src/Interfaces/CurrentRequestServiceInterface.php @@ -0,0 +1,19 @@ +createRequest(request()); + } +} diff --git a/src/Laravel/PassportServiceProvider.php b/src/Laravel/PassportServiceProvider.php index d6844ea..22a97f9 100644 --- a/src/Laravel/PassportServiceProvider.php +++ b/src/Laravel/PassportServiceProvider.php @@ -9,8 +9,10 @@ use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Signer\Key\InMemory; use League\OAuth2\Server\AuthorizationServer; +use Nyholm\Psr7\Response; use OpenIDConnect\ClaimExtractor; use OpenIDConnect\Claims\ClaimSet; +use OpenIDConnect\Grant\AuthCodeGrant; use OpenIDConnect\IdTokenResponse; class PassportServiceProvider extends Passport\PassportServiceProvider @@ -34,11 +36,14 @@ public function boot() ], ['openid', 'openid-config']); $this->loadRoutesFrom(__DIR__."/routes/web.php"); + + $this->registerClaimExtractor(); } public function makeAuthorizationServer(): AuthorizationServer { $cryptKey = $this->makeCryptKey('private'); + $encryptionKey = app(Encrypter::class)->getKey(); $customClaimSets = config('openid.custom_claim_sets'); @@ -54,7 +59,9 @@ public function makeAuthorizationServer(): AuthorizationServer InMemory::plainText($cryptKey->getKeyContents(), $cryptKey->getPassPhrase() ?? '') ), config('openid.token_headers'), - config('openid.use_microseconds') + config('openid.use_microseconds'), + app(LaravelCurrentRequestService::class), + $encryptionKey, ); return new AuthorizationServer( @@ -62,8 +69,36 @@ public function makeAuthorizationServer(): AuthorizationServer app(AccessTokenRepository::class), app(Passport\Bridge\ScopeRepository::class), $cryptKey, - app(Encrypter::class)->getKey(), + $encryptionKey, $responseType, ); } + + /** + * Build the Auth Code grant instance. + * + * @return AuthCodeGrant + */ + protected function buildAuthCodeGrant() + { + return new AuthCodeGrant( + $this->app->make(Passport\Bridge\AuthCodeRepository::class), + $this->app->make(Passport\Bridge\RefreshTokenRepository::class), + new \DateInterval('PT10M'), + new Response(), + $this->app->make(LaravelCurrentRequestService::class), + ); + } + + public function registerClaimExtractor() { + $this->app->singleton(ClaimExtractor::class, function () { + $customClaimSets = config('openid.custom_claim_sets'); + + $claimSets = array_map(function ($claimSet, $name) { + return new ClaimSet($name, $claimSet); + }, $customClaimSets, array_keys($customClaimSets)); + + return new ClaimExtractor(...$claimSets); + }); + } } diff --git a/src/Services/CurrentRequestService.php b/src/Services/CurrentRequestService.php new file mode 100644 index 0000000..827c8b8 --- /dev/null +++ b/src/Services/CurrentRequestService.php @@ -0,0 +1,24 @@ +request === null) { + throw new \RuntimeException('Request not set in CurrentRequestService'); + } + return $this->request; + } + + public function setRequest(ServerRequestInterface $request): void + { + $this->request = $request; + } +} From 7041c46a0ede1e1d5ba9e46e69cbe7e059214a8a Mon Sep 17 00:00:00 2001 From: Yorick Bosman Date: Wed, 24 Apr 2024 22:29:37 +0200 Subject: [PATCH 2/2] Fix code style --- src/Grant/AuthCodeGrant.php | 30 ++++++++++++-------- src/Laravel/LaravelCurrentRequestService.php | 1 - 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/Grant/AuthCodeGrant.php b/src/Grant/AuthCodeGrant.php index b8c029a..cfdc9cb 100644 --- a/src/Grant/AuthCodeGrant.php +++ b/src/Grant/AuthCodeGrant.php @@ -6,7 +6,6 @@ use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface; use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; use League\OAuth2\Server\RequestTypes\AuthorizationRequest; -use League\OAuth2\Server\ResponseTypes\RedirectResponse; use OpenIDConnect\Interfaces\CurrentRequestServiceInterface; use Psr\Http\Message\ResponseInterface; @@ -25,10 +24,16 @@ class AuthCodeGrant extends \League\OAuth2\Server\Grant\AuthCodeGrant * @param RefreshTokenRepositoryInterface $refreshTokenRepository * @param DateInterval $authCodeTTL * @param ResponseInterface $psr7Response An empty PSR-7 Response object - * @param CurrentRequestServiceInterface $currentRequestService A service that returns the current request. Used to get the nonce parameter. + * @param CurrentRequestServiceInterface + * $currentRequestService A service that returns the current request. Used to get the nonce parameter. * @throws \Exception */ - public function __construct(AuthCodeRepositoryInterface $authCodeRepository, RefreshTokenRepositoryInterface $refreshTokenRepository, DateInterval $authCodeTTL, ResponseInterface $psr7Response, CurrentRequestServiceInterface $currentRequestService) + public function __construct( + AuthCodeRepositoryInterface $authCodeRepository, + RefreshTokenRepositoryInterface $refreshTokenRepository, + DateInterval $authCodeTTL, + ResponseInterface $psr7Response, + CurrentRequestServiceInterface $currentRequestService) { parent::__construct($authCodeRepository, $refreshTokenRepository, $authCodeTTL); $this->psr7Response = $psr7Response; @@ -48,7 +53,8 @@ public function completeAuthorizationRequest(AuthorizationRequest $authorization $queryParams = $this->currentRequestService->getRequest()->getQueryParams(); if (isset($queryParams['nonce'])) { - // The only way to get the redirect URI is to generate the PSR7 response (The RedirectResponse class does not have a getter for the redirect URI) + // The only way to get the redirect URI is to generate the PSR7 response + // (The RedirectResponse class does not have a getter for the redirect URI) $httpResponse = $response->generateHttpResponse($this->psr7Response); $redirectUri = $httpResponse->getHeader('Location')[0]; $parsed = parse_url($redirectUri); @@ -77,14 +83,14 @@ public function completeAuthorizationRequest(AuthorizationRequest $authorization */ private function unparse_url($parsed_url) { - $scheme = isset($parsed_url['scheme']) ? $parsed_url['scheme'] . '://' : ''; - $host = isset($parsed_url['host']) ? $parsed_url['host'] : ''; - $port = isset($parsed_url['port']) ? ':' . $parsed_url['port'] : ''; - $user = isset($parsed_url['user']) ? $parsed_url['user'] : ''; - $pass = isset($parsed_url['pass']) ? ':' . $parsed_url['pass'] : ''; - $pass = ($user || $pass) ? "$pass@" : ''; - $path = isset($parsed_url['path']) ? $parsed_url['path'] : ''; - $query = isset($parsed_url['query']) ? '?' . $parsed_url['query'] : ''; + $scheme = isset($parsed_url['scheme']) ? $parsed_url['scheme'] . '://' : ''; + $host = isset($parsed_url['host']) ? $parsed_url['host'] : ''; + $port = isset($parsed_url['port']) ? ':' . $parsed_url['port'] : ''; + $user = isset($parsed_url['user']) ? $parsed_url['user'] : ''; + $pass = isset($parsed_url['pass']) ? ':' . $parsed_url['pass'] : ''; + $pass = ($user || $pass) ? "$pass@" : ''; + $path = isset($parsed_url['path']) ? $parsed_url['path'] : ''; + $query = isset($parsed_url['query']) ? '?' . $parsed_url['query'] : ''; $fragment = isset($parsed_url['fragment']) ? '#' . $parsed_url['fragment'] : ''; return "$scheme$user$pass$host$port$path$query$fragment"; } diff --git a/src/Laravel/LaravelCurrentRequestService.php b/src/Laravel/LaravelCurrentRequestService.php index a79f64a..48f4e0b 100644 --- a/src/Laravel/LaravelCurrentRequestService.php +++ b/src/Laravel/LaravelCurrentRequestService.php @@ -9,7 +9,6 @@ class LaravelCurrentRequestService implements CurrentRequestServiceInterface { - public function getRequest(): ServerRequestInterface { return (new PsrHttpFactory(