diff --git a/src/Grant/AuthCodeGrant.php b/src/Grant/AuthCodeGrant.php new file mode 100644 index 0000000..cfdc9cb --- /dev/null +++ b/src/Grant/AuthCodeGrant.php @@ -0,0 +1,97 @@ +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; + } +}