Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding support for a nonce #13

Merged
merged 2 commits into from
Apr 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions src/Grant/AuthCodeGrant.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

namespace OpenIDConnect\Grant;

use DateInterval;
use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface;
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
use League\OAuth2\Server\RequestTypes\AuthorizationRequest;
use OpenIDConnect\Interfaces\CurrentRequestServiceInterface;
use Psr\Http\Message\ResponseInterface;

/**
* This class extends the default AuthCodeGrant class to add support for the nonce parameter.
*
* The nonce parameter is
*/
class AuthCodeGrant extends \League\OAuth2\Server\Grant\AuthCodeGrant
{
private ResponseInterface $psr7Response;
private CurrentRequestServiceInterface $currentRequestService;

/**
* @param AuthCodeRepositoryInterface $authCodeRepository
* @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.
* @throws \Exception
*/
public function __construct(
AuthCodeRepositoryInterface $authCodeRepository,
RefreshTokenRepositoryInterface $refreshTokenRepository,
DateInterval $authCodeTTL,
ResponseInterface $psr7Response,
CurrentRequestServiceInterface $currentRequestService)
{
parent::__construct($authCodeRepository, $refreshTokenRepository, $authCodeTTL);
$this->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";
}
}
40 changes: 32 additions & 8 deletions src/IdTokenResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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(
Expand All @@ -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 [];
}
Expand All @@ -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(),
Expand All @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions src/Interfaces/CurrentRequestServiceInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace OpenIDConnect\Interfaces;

use Psr\Http\Message\ServerRequestInterface;

/**
* A service in charge of returning the current request.
*
* This should be implemented by the application using this package (a default Laravel implementation is provided)
*
* We need this because due to the architecture of the League package, the request is not available in the
* grant classes. But we need access to the "nonce" parameter in the request to be able to include it in the
* ID token.
*/
interface CurrentRequestServiceInterface
{
public function getRequest(): ServerRequestInterface;
}
21 changes: 21 additions & 0 deletions src/Laravel/LaravelCurrentRequestService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace OpenIDConnect\Laravel;

use Nyholm\Psr7\Factory\Psr17Factory;
use OpenIDConnect\Interfaces\CurrentRequestServiceInterface;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;

class LaravelCurrentRequestService implements CurrentRequestServiceInterface
{
public function getRequest(): ServerRequestInterface
{
return (new PsrHttpFactory(
new Psr17Factory,
new Psr17Factory,
new Psr17Factory,
new Psr17Factory
))->createRequest(request());
}
}
39 changes: 37 additions & 2 deletions src/Laravel/PassportServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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');

Expand All @@ -54,16 +59,46 @@ 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(
app(ClientRepository::class),
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);
});
}
}
24 changes: 24 additions & 0 deletions src/Services/CurrentRequestService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace OpenIDConnect\Services;

use OpenIDConnect\Interfaces\CurrentRequestServiceInterface;
use Psr\Http\Message\ServerRequestInterface;

class CurrentRequestService implements CurrentRequestServiceInterface
{
private ?ServerRequestInterface $request;

public function getRequest(): ServerRequestInterface
{
if ($this->request === null) {
throw new \RuntimeException('Request not set in CurrentRequestService');
}
return $this->request;
}

public function setRequest(ServerRequestInterface $request): void
{
$this->request = $request;
}
}
Loading