diff --git a/examples/src/Repositories/IdTokenRepository.php b/examples/src/Repositories/IdTokenRepository.php new file mode 100644 index 000000000..849e4dd84 --- /dev/null +++ b/examples/src/Repositories/IdTokenRepository.php @@ -0,0 +1,43 @@ + + * @license http://opensource.org/licenses/MIT MIT + */ +class IdTokenRepository implements IdTokenRepositoryInterface +{ + public function __construct(private string $issuedBy, private ?string $nonce = null) + { + } + + /** + * {@inheritdoc} + */ + public function getBuilder(AccessTokenEntityInterface $accessToken): Builder + { + $builder = (new Builder(new JoseEncoder(), ChainedFormatter::withUnixTimestampDates())) + ->permittedFor($accessToken->getClient()->getIdentifier()) + ->issuedBy($this->issuedBy) + ->issuedAt(new DateTimeImmutable()) + ->expiresAt($accessToken->getExpiryDateTime()) + ->relatedTo($accessToken->getUserIdentifier()); + + if ($this->nonce) { + $builder->withClaim('nonce', $this->nonce); + } + + return $builder; + } +} diff --git a/src/ClaimExtractor.php b/src/ClaimExtractor.php new file mode 100644 index 000000000..c50b11653 --- /dev/null +++ b/src/ClaimExtractor.php @@ -0,0 +1,178 @@ + + * @author Marc Riemer + */ +class ClaimExtractor implements ClaimExtractorInterface +{ + /** + * claimSetEntries + * + * @var ClaimSetEntryInterface[] + */ + protected array $claimSetEntries = []; + + /** + * Protected claims + * + * @var string[] + */ + protected array $protectedClaims = ['profile', 'email', 'address', 'phone']; + + /** + * ClaimExtractor constructor + * + * @param array $claimSetEntries + */ + public function __construct(array $claimSetEntries = []) + { + $this->claimSetEntries = self::getDefaultClaimSetEnties(); + foreach ($claimSetEntries as $claimSetEntry) { + $this->addClaimSetEntry($claimSetEntry); + } + } + + /** + * @return $this + * + * @throws \InvalidArgumentException + */ + public function addClaimSetEntry(ClaimSetEntryInterface $claimSetEntry): ClaimExtractor + { + if (in_array($claimSetEntry->getScope(), $this->protectedClaims) && !$this->getClaimSetEntry($claimSetEntry->getScope())) { + throw new InvalidArgumentException( + sprintf('%s is a protected scope and is pre-defined by the OpenID Connect specification.', $claimSetEntry->getScope()) + ); + } + + $this->claimSetEntries[] = $claimSetEntry; + + return $this; + } + + public function getClaimSetEntry(string $scope): ?ClaimSetEntryInterface + { + foreach ($this->claimSetEntries as $entry) { + if ($entry->getScope() === $scope) { + return $entry; + } + } + + return null; + } + + /** + * Get claimSetEntries + * + * @return array + */ + public function getClaimSetEntries(): array + { + return $this->claimSetEntries; + } + + /** + * {@inheritdoc} + */ + public function extract(array $scopes, array $claims): array + { + $claimData = []; + $keys = array_keys($claims); + + foreach ($scopes as $scope) { + $scopeName = ($scope instanceof ScopeEntityInterface) ? $scope->getIdentifier() : $scope; + + $claimSet = $this->getClaimSetEntry($scopeName); + if (null === $claimSet) { + continue; + } + + $intersected = array_intersect($claimSet->getClaims(), $keys); + + if (empty($intersected)) { + continue; + } + + $data = array_filter( + $claims, + function ($key) use ($intersected) { + return in_array($key, $intersected); + }, + ARRAY_FILTER_USE_KEY + ); + + $claimData = array_merge($claimData, $data); + } + + return $claimData; + } + + /** + * Create a array default openID connect claims + * + * @see http://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims + * + * @return ClaimSetEntry[] + */ + public static function getDefaultClaimSetEnties(): array + { + return [ + new ClaimSetEntry('profile', [ + 'name', + 'family_name', + 'given_name', + 'middle_name', + 'nickname', + 'preferred_username', + 'profile', + 'picture', + 'website', + 'gender', + 'birthdate', + 'zoneinfo', + 'locale', + 'updated_at', + ]), + new ClaimSetEntry('email', [ + 'email', + 'email_verified', + ]), + new ClaimSetEntry('address', [ + 'address', + ]), + new ClaimSetEntry('phone', [ + 'phone_number', + 'phone_number_verified', + ]), + new ClaimSetEntry('openid', [ + 'nonce', + 'auth_time', + 'acr', + 'amr', + 'azp', + ]), + ]; + } +} diff --git a/src/ClaimExtractorInterface.php b/src/ClaimExtractorInterface.php new file mode 100644 index 000000000..220ca138c --- /dev/null +++ b/src/ClaimExtractorInterface.php @@ -0,0 +1,20 @@ + $scopes + * @param array $claims + * + * @return array + */ + public function extract(array $scopes, array $claims): array; +} diff --git a/src/Entities/ClaimSetEntry.php b/src/Entities/ClaimSetEntry.php new file mode 100644 index 000000000..db273f36e --- /dev/null +++ b/src/Entities/ClaimSetEntry.php @@ -0,0 +1,45 @@ + + * @author Marc Riemer + * @license http://opensource.org/licenses/MIT MIT + */ +class ClaimSetEntry implements ClaimSetEntryInterface +{ + /** + * Summary of __construct + * + * @param string $scope Scope of the claimset + * @param string[] $claims The claims + */ + public function __construct( + protected string $scope, + protected array $claims + ) { + } + + /** + * Get scope + */ + public function getScope(): string + { + return $this->scope; + } + + /** + * Get claims + * + * @return array + */ + public function getClaims(): array + { + return $this->claims; + } +} diff --git a/src/Entities/ClaimSetEntryInterface.php b/src/Entities/ClaimSetEntryInterface.php new file mode 100644 index 000000000..29878194b --- /dev/null +++ b/src/Entities/ClaimSetEntryInterface.php @@ -0,0 +1,17 @@ + + * @author Marc Riemer + * @license http://opensource.org/licenses/MIT MIT + */ +interface ClaimSetEntryInterface extends ClaimSetInterface +{ + public function getScope(): string; +} diff --git a/src/Entities/ClaimSetInterface.php b/src/Entities/ClaimSetInterface.php new file mode 100644 index 000000000..724872eb6 --- /dev/null +++ b/src/Entities/ClaimSetInterface.php @@ -0,0 +1,22 @@ + + * @author Marc Riemer + * @license http://opensource.org/licenses/MIT MIT + */ +interface ClaimSetInterface +{ + /** + * Get Claims + * + * @return array + */ + public function getClaims(): array; +} diff --git a/src/IdTokenClaimsCreatedEvent.php b/src/IdTokenClaimsCreatedEvent.php new file mode 100644 index 000000000..34fe5312b --- /dev/null +++ b/src/IdTokenClaimsCreatedEvent.php @@ -0,0 +1,33 @@ + + */ +final class IdTokenClaimsCreatedEvent extends IdTokenEvent +{ + /** + * Builder + */ + private Builder $builder; + + public function __construct(string $name, Builder $builder) + { + parent::__construct($name); + $this->builder = $builder; + } + + public function getBuilder(): Builder + { + return $this->builder; + } +} diff --git a/src/IdTokenEvent.php b/src/IdTokenEvent.php new file mode 100644 index 000000000..baa3dcb38 --- /dev/null +++ b/src/IdTokenEvent.php @@ -0,0 +1,20 @@ + + */ +class IdTokenEvent extends AbstractEvent +{ + public const ID_TOKEN_ISSUED = 'id_token.issued'; + + // This event can be used to extent claims of the id_token + public const ID_TOKEN_CLAIMS_CREATED = 'id_token.claims.created'; +} diff --git a/src/IdTokenIssuedEvent.php b/src/IdTokenIssuedEvent.php new file mode 100644 index 000000000..abe3e173b --- /dev/null +++ b/src/IdTokenIssuedEvent.php @@ -0,0 +1,34 @@ + + */ +final class IdTokenIssuedEvent extends IdTokenEvent +{ + /** + * Token + */ + private Token $token; + + /** + * Get Token + */ + public function getToken(): Token + { + return $this->token; + } + + public function __construct(mixed $name, Token $token) + { + parent::__construct($name); + $this->token = $token; + } +} diff --git a/src/Repositories/ClaimSetRepositoryInterface.php b/src/Repositories/ClaimSetRepositoryInterface.php new file mode 100644 index 000000000..3b4280d69 --- /dev/null +++ b/src/Repositories/ClaimSetRepositoryInterface.php @@ -0,0 +1,20 @@ + + * @author Steve Rhoades + * @license http://opensource.org/licenses/MIT MIT + */ +interface ClaimSetRepositoryInterface +{ + public function getClaimSet(AccessTokenEntityInterface $authCode): ClaimSetInterface; +} diff --git a/src/Repositories/IdTokenRepositoryInterface.php b/src/Repositories/IdTokenRepositoryInterface.php new file mode 100644 index 000000000..824700abd --- /dev/null +++ b/src/Repositories/IdTokenRepositoryInterface.php @@ -0,0 +1,24 @@ + + * @license http://opensource.org/licenses/MIT MIT + */ +interface IdTokenRepositoryInterface +{ + /** + * Creates new token builder and may add some standard claims + * + * @param AccessTokenEntityInterface $token Issued access token + */ + public function getBuilder(AccessTokenEntityInterface $token): Builder; +} diff --git a/src/ResponseTypes/IdTokenResponse.php b/src/ResponseTypes/IdTokenResponse.php new file mode 100644 index 000000000..508bf93a5 --- /dev/null +++ b/src/ResponseTypes/IdTokenResponse.php @@ -0,0 +1,124 @@ + + * @author Marc Riemer + */ +class IdTokenResponse extends BearerTokenResponse +{ + use EmitterAwarePolyfill; + + /** + * IdTokenRepositoryInterface + */ + protected IdTokenRepositoryInterface $idTokenRepository; + + /** + * ClaimSetRepositoryInterface + */ + protected ClaimSetRepositoryInterface $claimRepository; + + /** + * ClaimExtractorInterface + */ + protected ClaimExtractorInterface $extractor; + + public function __construct( + IdTokenRepositoryInterface $idTokenRepository, + ClaimSetRepositoryInterface $claimRepository, + EventEmitter $emitter, + ?ClaimExtractorInterface $extractor = null + ) { + if (!$extractor) { + $this->extractor = new ClaimExtractor(); + } else { + $this->extractor = $extractor; + } + $this->idTokenRepository = $idTokenRepository; + $this->claimRepository = $claimRepository; + $this->setEmitter($emitter); + } + + /** + * Add custom fields to your Bearer Token response here, then override + * AuthorizationServer::getResponseType() to pull in your version of + * this class rather than the default. + * + * @return array + */ + protected function getExtraParams(AccessTokenEntityInterface $accessToken): array + { + // Onyly add id_token to openid scopes + if (!self::isOpenIDRequest($accessToken->getScopes())) { + return []; + } + + $claimSet = $this->claimRepository->getClaimSet($accessToken); + + $builder = $this->idTokenRepository->getBuilder($accessToken); + + if ($claimSet instanceof ClaimSetInterface) { + foreach ($this->extractor->extract($accessToken->getScopes(), $claimSet->getClaims()) as $claimName => $claimValue) { + $builder = $builder->withClaim($claimName, $claimValue); + } + } + + $this->getEmitter()->emit( + new IdTokenClaimsCreatedEvent(IdTokenEvent::ID_TOKEN_CLAIMS_CREATED, $builder) + ); + + $token = $builder->getToken( + new Sha256(), + InMemory::file($this->privateKey->getKeyPath(), (string) $this->privateKey->getPassPhrase()) + ); + + $this->getEmitter()->emit( + new IdTokenIssuedEvent(IdTokenEvent::ID_TOKEN_ISSUED, $token) + ); + + return [ + 'id_token' => $token->toString(), + ]; + } + + /** + * Return true If this is an OpenID request + * + * @param ScopeEntityInterface[] $scopes + */ + private static function isOpenIDRequest(array $scopes): bool + { + foreach ($scopes as $scope) { + if ($scope instanceof ScopeEntityInterface) { + if ($scope->getIdentifier() === 'openid') { + return true; + } + } + } + + return false; + } +} diff --git a/src/ResponseTypes/UserInfoResponse.php b/src/ResponseTypes/UserInfoResponse.php new file mode 100644 index 000000000..7798f21ba --- /dev/null +++ b/src/ResponseTypes/UserInfoResponse.php @@ -0,0 +1,39 @@ + + */ +class UserInfoResponse extends AbstractResponseType +{ + public function __construct( + protected ClaimSetInterface $claimSet + ) { + } + + /** + * {@inheritdoc} + */ + public function generateHttpResponse(ResponseInterface $response): ResponseInterface + { + $response = $response + ->withStatus(200) + ->withHeader('pragma', 'no-cache') + ->withHeader('cache-control', 'no-store') + ->withHeader('content-type', 'application/json; charset=UTF-8'); + + $response->getBody()->write(json_encode($this->claimSet->getClaims())); + + return $response; + } +} diff --git a/tests/ResponseTypes/BearerResponseTypeTest.php b/tests/ResponseTypes/BearerResponseTypeTest.php index 386fb628b..7ca51431e 100644 --- a/tests/ResponseTypes/BearerResponseTypeTest.php +++ b/tests/ResponseTypes/BearerResponseTypeTest.php @@ -8,21 +8,45 @@ use DateTimeImmutable; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequest; +use Lcobucci\Clock\SystemClock; +use Lcobucci\JWT\Builder; +use Lcobucci\JWT\Encoding\ChainedFormatter; +use Lcobucci\JWT\Encoding\JoseEncoder; +use Lcobucci\JWT\Signer\Key\InMemory; +use Lcobucci\JWT\Signer\Rsa\Sha256; +use Lcobucci\JWT\Token\Builder as TokenBuilder; +use Lcobucci\JWT\Token\Parser; +use Lcobucci\JWT\Validation\Constraint\HasClaimWithValue; +use Lcobucci\JWT\Validation\Constraint\IssuedBy; +use Lcobucci\JWT\Validation\Constraint\LooseValidAt; +use Lcobucci\JWT\Validation\Constraint\PermittedFor; +use Lcobucci\JWT\Validation\Constraint\RelatedTo; +use Lcobucci\JWT\Validation\Constraint\SignedWith; +use Lcobucci\JWT\Validation\Validator; use League\OAuth2\Server\AuthorizationValidators\BearerTokenValidator; +use League\OAuth2\Server\ClaimExtractor; use League\OAuth2\Server\CryptKey; +use League\OAuth2\Server\Entities\AccessTokenEntityInterface; +use League\OAuth2\Server\Entities\ClaimSetInterface; +use League\OAuth2\Server\EventEmitting\EventEmitter; use League\OAuth2\Server\Exception\OAuthServerException; use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface; +use League\OAuth2\Server\Repositories\ClaimSetRepositoryInterface; +use League\OAuth2\Server\Repositories\IdTokenRepositoryInterface; use League\OAuth2\Server\ResponseTypes\BearerTokenResponse; +use League\OAuth2\Server\ResponseTypes\IdTokenResponse; use LeagueTests\Stubs\AccessTokenEntity; use LeagueTests\Stubs\ClientEntity; use LeagueTests\Stubs\RefreshTokenEntity; use LeagueTests\Stubs\ScopeEntity; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\ResponseInterface; use function base64_encode; use function json_decode; use function random_bytes; use function sprintf; +use function uniqid; class BearerResponseTypeTest extends TestCase { @@ -292,4 +316,144 @@ public function testDetermineMissingBearerInHeader(): void ); } } + + public function testGenerateHttpResponseWithIdToken(): void + { + $claimSetRepository = new class () implements ClaimSetRepositoryInterface { + public function getClaimSet(AccessTokenEntityInterface $accessToken): ClaimSetInterface + { + $claimSet = new class () implements ClaimSetInterface { + public string $scope = 'openid'; + + /** + * @var array + */ + public array $claims = ['acr' => 'pop']; + + public function getScope(): string + { + return $this->scope; + } + + /** + * @return array $claims + */ + public function getClaims(): array + { + return $this->claims; + } + }; + + return $claimSet; + } + }; + + $IdTokenRepository = (new class () implements IdTokenRepositoryInterface { + public function getBuilder(AccessTokenEntityInterface $accessToken): Builder + { + $builder = (new TokenBuilder( + new JoseEncoder(), + ChainedFormatter::withUnixTimestampDates() + )) + ->permittedFor($accessToken->getClient()->getIdentifier()) + ->issuedBy('https://example.com') + ->issuedAt(new DateTimeImmutable()) + ->expiresAt($accessToken->getExpiryDateTime()) + ->relatedTo($accessToken->getUserIdentifier()) + ->withClaim('nonce', 's6G31Kolwu9p'); + + return $builder; + } + }); + + $responseType = new IdTokenResponse( + $IdTokenRepository, + $claimSetRepository, + new EventEmitter(), + $claimExtrator = new ClaimExtractor() + ); + + $responseType->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); + $responseType->setEncryptionKey(base64_encode(random_bytes(36))); + + $client = new ClientEntity(); + $client->setIdentifier('clientName'); + + $openidScope = new ScopeEntity(); + $openidScope->setIdentifier('openid'); + + $emailScope = new ScopeEntity(); + $emailScope->setIdentifier('email'); + + $profileScope = new ScopeEntity(); + $profileScope->setIdentifier('profile'); + + $accessToken = new AccessTokenEntity(); + $accessToken->setIdentifier(uniqid()); + $accessToken->setExpiryDateTime((new DateTimeImmutable())->add(new DateInterval('PT1H'))); + $accessToken->setClient($client); + + $accessToken->addScope($openidScope); + $accessToken->addScope($emailScope); + $accessToken->addScope($profileScope); + + $accessToken->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); + $accessToken->setUserIdentifier(uniqid()); + + $refreshToken = new RefreshTokenEntity(); + $refreshToken->setIdentifier(uniqid()); + $refreshToken->setAccessToken($accessToken); + $refreshToken->setExpiryDateTime((new DateTimeImmutable())->add(new DateInterval('PT1H'))); + + $responseType->setAccessToken($accessToken); + $responseType->setRefreshToken($refreshToken); + + $response = $responseType->generateHttpResponse(new Response()); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('no-cache', $response->getHeader('pragma')[0]); + $this->assertEquals('no-store', $response->getHeader('cache-control')[0]); + $this->assertEquals('application/json; charset=UTF-8', $response->getHeader('content-type')[0]); + + $response->getBody()->rewind(); + $json = json_decode($response->getBody()->getContents()); + $this->assertEquals('Bearer', $json->token_type); + + foreach (['expires_in', 'access_token', 'refresh_token', 'id_token'] as $claim) { + self::assertTrue(property_exists($json, $claim)); + } + + $token = (new Parser(new JoseEncoder()))->parse($json->id_token); + + $validator = new Validator(); + + $this->assertTrue($validator->validate( + $token, + new SignedWith(new Sha256(), InMemory::file(__DIR__ . '/../Stubs/public.key', '')) + )); + + $this->assertTrue($validator->validate( + $token, + new IssuedBy('https://example.com') + )); + + $this->assertTrue($validator->validate( + $token, + new PermittedFor($client->getIdentifier()) + )); + + $this->assertTrue($validator->validate( + $token, + new RelatedTo($accessToken->getUserIdentifier()) + )); + + $this->assertTrue($validator->validate( + $token, + new LooseValidAt(new SystemClock($accessToken->getExpiryDateTime()->getTimezone())) + )); + + $this->assertTrue($validator->validate($token, new HasClaimWithValue('acr', 'pop'))); + $this->assertTrue($validator->validate($token, new HasClaimWithValue('nonce', 's6G31Kolwu9p'))); + } }