diff --git a/README.md b/README.md index 5ed6a76ac..586fec265 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,10 @@ The following RFCs are implemented: * [RFC6749 "OAuth 2.0"](https://tools.ietf.org/html/rfc6749) * [RFC6750 "The OAuth 2.0 Authorization Framework: Bearer Token Usage"](https://tools.ietf.org/html/rfc6750) +* [RFC7009 "OAuth 2.0 Token Revocation"](https://tools.ietf.org/html/rfc7009) * [RFC7519 "JSON Web Token (JWT)"](https://tools.ietf.org/html/rfc7519) * [RFC7636 "Proof Key for Code Exchange by OAuth Public Clients"](https://tools.ietf.org/html/rfc7636) +* [RFC7662 "OAuth 2.0 Token Introspection"](https://tools.ietf.org/html/rfc7662) * [RFC8628 "OAuth 2.0 Device Authorization Grant](https://tools.ietf.org/html/rfc8628) This library was created by Alex Bilbie. Find him on Twitter at [@alexbilbie](https://twitter.com/alexbilbie). diff --git a/src/AbstractHandler.php b/src/AbstractHandler.php new file mode 100644 index 000000000..8b8bca482 --- /dev/null +++ b/src/AbstractHandler.php @@ -0,0 +1,282 @@ +clientRepository = $clientRepository; + } + + public function setAccessTokenRepository(AccessTokenRepositoryInterface $accessTokenRepository): void + { + $this->accessTokenRepository = $accessTokenRepository; + } + + public function setRefreshTokenRepository(RefreshTokenRepositoryInterface $refreshTokenRepository): void + { + $this->refreshTokenRepository = $refreshTokenRepository; + } + + /** + * Validate the client. + * + * @throws OAuthServerException + */ + protected function validateClient(ServerRequestInterface $request): ClientEntityInterface + { + [$clientId, $clientSecret] = $this->getClientCredentials($request); + + $client = $this->getClientEntityOrFail($clientId, $request); + + if ($client->isConfidential()) { + if ($clientSecret === '') { + throw OAuthServerException::invalidRequest('client_secret'); + } + + if ( + $this->clientRepository->validateClient( + $clientId, + $clientSecret, + $this instanceof GrantTypeInterface ? $this->getIdentifier() : null + ) === false + ) { + $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); + + throw OAuthServerException::invalidClient($request); + } + } + + return $client; + } + + /** + * Wrapper around ClientRepository::getClientEntity() that ensures we emit + * an event and throw an exception if the repo doesn't return a client + * entity. + * + * This is a bit of defensive coding because the interface contract + * doesn't actually enforce non-null returns/exception-on-no-client so + * getClientEntity might return null. By contrast, this method will + * always either return a ClientEntityInterface or throw. + * + * @throws OAuthServerException + */ + protected function getClientEntityOrFail(string $clientId, ServerRequestInterface $request): ClientEntityInterface + { + $client = $this->clientRepository->getClientEntity($clientId); + + if ($client instanceof ClientEntityInterface === false) { + $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); + throw OAuthServerException::invalidClient($request); + } + + return $client; + } + + /** + * Gets the client credentials from the request from the request body or + * the Http Basic Authorization header + * + * @return array{0:non-empty-string,1:string} + * + * @throws OAuthServerException + */ + protected function getClientCredentials(ServerRequestInterface $request): array + { + [$basicAuthUser, $basicAuthPassword] = $this->getBasicAuthCredentials($request); + + $clientId = $this->getRequestParameter('client_id', $request, $basicAuthUser); + + if ($clientId === null) { + throw OAuthServerException::invalidRequest('client_id'); + } + + $clientSecret = $this->getRequestParameter('client_secret', $request, $basicAuthPassword); + + return [$clientId, $clientSecret ?? '']; + } + + /** + * Parse request parameter. + * + * @param array $request + * + * @return non-empty-string|null + * + * @throws OAuthServerException + */ + private static function parseParam(string $parameter, array $request, ?string $default = null): ?string + { + $value = $request[$parameter] ?? ''; + + if (is_scalar($value)) { + $value = trim((string) $value); + } else { + throw OAuthServerException::invalidRequest($parameter); + } + + if ($value === '') { + $value = $default === null ? null : trim($default); + + if ($value === '') { + $value = null; + } + } + + return $value; + } + + /** + * Retrieve request parameter. + * + * @return non-empty-string|null + * + * @throws OAuthServerException + */ + protected function getRequestParameter(string $parameter, ServerRequestInterface $request, ?string $default = null): ?string + { + return self::parseParam($parameter, (array) $request->getParsedBody(), $default); + } + + /** + * Retrieve HTTP Basic Auth credentials with the Authorization header + * of a request. First index of the returned array is the username, + * second is the password (so list() will work). If the header does + * not exist, or is otherwise an invalid HTTP Basic header, return + * [null, null]. + * + * @return array{0:non-empty-string,1:string}|array{0:null,1:null} + */ + protected function getBasicAuthCredentials(ServerRequestInterface $request): array + { + if (!$request->hasHeader('Authorization')) { + return [null, null]; + } + + $header = $request->getHeader('Authorization')[0]; + if (stripos($header, 'Basic ') !== 0) { + return [null, null]; + } + + $decoded = base64_decode(substr($header, 6), true); + + if ($decoded === false) { + return [null, null]; + } + + if (str_contains($decoded, ':') === false) { + return [null, null]; // HTTP Basic header without colon isn't valid + } + + [$username, $password] = explode(':', $decoded, 2); + + if ($username === '') { + return [null, null]; + } + + return [$username, $password]; + } + + /** + * Retrieve query string parameter. + * + * @return non-empty-string|null + * + * @throws OAuthServerException + */ + protected function getQueryStringParameter(string $parameter, ServerRequestInterface $request, ?string $default = null): ?string + { + return self::parseParam($parameter, $request->getQueryParams(), $default); + } + + /** + * Retrieve cookie parameter. + * + * @return non-empty-string|null + * + * @throws OAuthServerException + */ + protected function getCookieParameter(string $parameter, ServerRequestInterface $request, ?string $default = null): ?string + { + return self::parseParam($parameter, $request->getCookieParams(), $default); + } + + /** + * Retrieve server parameter. + * + * @return non-empty-string|null + * + * @throws OAuthServerException + */ + protected function getServerParameter(string $parameter, ServerRequestInterface $request, ?string $default = null): ?string + { + return self::parseParam($parameter, $request->getServerParams(), $default); + } + + /** + * Validate the given encrypted refresh token. + * + * @throws OAuthServerException + * + * @return array + */ + protected function validateEncryptedRefreshToken( + ServerRequestInterface $request, + string $encryptedRefreshToken, + string $clientId + ): array { + try { + $refreshToken = $this->decrypt($encryptedRefreshToken); + } catch (Exception $e) { + throw OAuthServerException::invalidRefreshToken('Cannot decrypt the refresh token', $e); + } + + $refreshTokenData = json_decode($refreshToken, true); + + if ($refreshTokenData['client_id'] !== $clientId) { + $this->getEmitter()->emit(new RequestEvent(RequestEvent::REFRESH_TOKEN_CLIENT_FAILED, $request)); + throw OAuthServerException::invalidRefreshToken('Token is not linked to client'); + } + + if ($refreshTokenData['expire_time'] < time()) { + throw OAuthServerException::invalidRefreshToken('Token has expired'); + } + + if ($this->refreshTokenRepository->isRefreshTokenRevoked($refreshTokenData['refresh_token_id']) === true) { + throw OAuthServerException::invalidRefreshToken('Token has been revoked'); + } + + return $refreshTokenData; + } +} diff --git a/src/AuthorizationValidators/BearerTokenValidator.php b/src/AuthorizationValidators/BearerTokenValidator.php index 0442dd48e..f03254a2e 100644 --- a/src/AuthorizationValidators/BearerTokenValidator.php +++ b/src/AuthorizationValidators/BearerTokenValidator.php @@ -34,7 +34,7 @@ use function preg_replace; use function trim; -class BearerTokenValidator implements AuthorizationValidatorInterface +class BearerTokenValidator implements AuthorizationValidatorInterface, JwtValidatorInterface { use CryptTrait; @@ -99,6 +99,21 @@ public function validateAuthorization(ServerRequestInterface $request): ServerRe throw OAuthServerException::accessDenied('Missing "Bearer" token'); } + $claims = $this->validateJwt($request, $jwt); + + // Return the request with additional attributes + return $request + ->withAttribute('oauth_access_token_id', $claims['jti'] ?? null) + ->withAttribute('oauth_client_id', $claims['aud'][0] ?? null) + ->withAttribute('oauth_user_id', $claims['sub'] ?? null) + ->withAttribute('oauth_scopes', $claims['scopes'] ?? null); + } + + /** + * {@inheritdoc} + */ + public function validateJwt(ServerRequestInterface $request, string $jwt, ?string $clientId = null): array + { try { // Attempt to parse the JWT $token = $this->jwtConfiguration->parser()->parse($jwt); @@ -120,16 +135,20 @@ public function validateAuthorization(ServerRequestInterface $request): ServerRe $claims = $token->claims(); + // Check if token is linked to the client + if ( + $clientId !== null && + $claims->get('client_id') !== $clientId && + !$token->isPermittedFor($clientId) + ) { + throw OAuthServerException::accessDenied('Access token is not linked to client'); + } + // Check if token has been revoked if ($this->accessTokenRepository->isAccessTokenRevoked($claims->get('jti'))) { throw OAuthServerException::accessDenied('Access token has been revoked'); } - // Return the request with additional attributes - return $request - ->withAttribute('oauth_access_token_id', $claims->get('jti')) - ->withAttribute('oauth_client_id', $claims->get('aud')[0]) - ->withAttribute('oauth_user_id', $claims->get('sub')) - ->withAttribute('oauth_scopes', $claims->get('scopes')); + return $claims->all(); } } diff --git a/src/AuthorizationValidators/JwtValidatorInterface.php b/src/AuthorizationValidators/JwtValidatorInterface.php new file mode 100644 index 000000000..e71787b8d --- /dev/null +++ b/src/AuthorizationValidators/JwtValidatorInterface.php @@ -0,0 +1,20 @@ + + */ + public function validateJwt(ServerRequestInterface $request, string $jwt, ?string $clientId = null): array; +} diff --git a/src/Exception/OAuthServerException.php b/src/Exception/OAuthServerException.php index df8b58a3d..896b0fd89 100644 --- a/src/Exception/OAuthServerException.php +++ b/src/Exception/OAuthServerException.php @@ -265,6 +265,20 @@ public static function unauthorizedClient(?string $hint = null): static ); } + /** + * Unsupported Token Type error. + */ + public static function unsupportedTokenType(?string $hint = null): static + { + return new static( + 'The authorization server does not support the revocation of the presented token type.', + 15, + 'unsupported_token_type', + 400, + $hint + ); + } + /** * Generate a HTTP response. */ diff --git a/src/Grant/AbstractGrant.php b/src/Grant/AbstractGrant.php index 7c27e95c5..0ec1c1044 100644 --- a/src/Grant/AbstractGrant.php +++ b/src/Grant/AbstractGrant.php @@ -19,21 +19,17 @@ use DomainException; use Error; use Exception; +use League\OAuth2\Server\AbstractHandler; use League\OAuth2\Server\CryptKeyInterface; -use League\OAuth2\Server\CryptTrait; use League\OAuth2\Server\Entities\AccessTokenEntityInterface; use League\OAuth2\Server\Entities\AuthCodeEntityInterface; use League\OAuth2\Server\Entities\ClientEntityInterface; use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; use League\OAuth2\Server\Entities\ScopeEntityInterface; -use League\OAuth2\Server\EventEmitting\EmitterAwarePolyfill; use League\OAuth2\Server\Exception\OAuthServerException; use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException; use League\OAuth2\Server\RedirectUriValidators\RedirectUriValidator; -use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface; use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface; -use League\OAuth2\Server\Repositories\ClientRepositoryInterface; -use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; use League\OAuth2\Server\Repositories\ScopeRepositoryInterface; use League\OAuth2\Server\Repositories\UserRepositoryInterface; use League\OAuth2\Server\RequestEvent; @@ -46,36 +42,25 @@ use function array_filter; use function array_key_exists; -use function base64_decode; use function bin2hex; use function explode; use function is_string; use function random_bytes; -use function substr; use function trim; /** * Abstract grant class. */ -abstract class AbstractGrant implements GrantTypeInterface +abstract class AbstractGrant extends AbstractHandler implements GrantTypeInterface { - use EmitterAwarePolyfill; - use CryptTrait; - protected const SCOPE_DELIMITER_STRING = ' '; protected const MAX_RANDOM_TOKEN_GENERATION_ATTEMPTS = 10; - protected ClientRepositoryInterface $clientRepository; - - protected AccessTokenRepositoryInterface $accessTokenRepository; - protected ScopeRepositoryInterface $scopeRepository; protected AuthCodeRepositoryInterface $authCodeRepository; - protected RefreshTokenRepositoryInterface $refreshTokenRepository; - protected UserRepositoryInterface $userRepository; protected DateInterval $refreshTokenTTL; @@ -86,26 +71,11 @@ abstract class AbstractGrant implements GrantTypeInterface protected bool $revokeRefreshTokens = true; - public function setClientRepository(ClientRepositoryInterface $clientRepository): void - { - $this->clientRepository = $clientRepository; - } - - public function setAccessTokenRepository(AccessTokenRepositoryInterface $accessTokenRepository): void - { - $this->accessTokenRepository = $accessTokenRepository; - } - public function setScopeRepository(ScopeRepositoryInterface $scopeRepository): void { $this->scopeRepository = $scopeRepository; } - public function setRefreshTokenRepository(RefreshTokenRepositoryInterface $refreshTokenRepository): void - { - $this->refreshTokenRepository = $refreshTokenRepository; - } - public function setAuthCodeRepository(AuthCodeRepositoryInterface $authCodeRepository): void { $this->authCodeRepository = $authCodeRepository; @@ -143,51 +113,11 @@ public function revokeRefreshTokens(bool $willRevoke): void } /** - * Validate the client. - * - * @throws OAuthServerException - */ - protected function validateClient(ServerRequestInterface $request): ClientEntityInterface - { - [$clientId, $clientSecret] = $this->getClientCredentials($request); - - $client = $this->getClientEntityOrFail($clientId, $request); - - if ($client->isConfidential()) { - if ($clientSecret === '') { - throw OAuthServerException::invalidRequest('client_secret'); - } - - if ($this->clientRepository->validateClient($clientId, $clientSecret, $this->getIdentifier()) === false) { - $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); - - throw OAuthServerException::invalidClient($request); - } - } - - return $client; - } - - /** - * Wrapper around ClientRepository::getClientEntity() that ensures we emit - * an event and throw an exception if the repo doesn't return a client - * entity. - * - * This is a bit of defensive coding because the interface contract - * doesn't actually enforce non-null returns/exception-on-no-client so - * getClientEntity might return null. By contrast, this method will - * always either return a ClientEntityInterface or throw. - * - * @throws OAuthServerException + * {@inheritdoc} */ protected function getClientEntityOrFail(string $clientId, ServerRequestInterface $request): ClientEntityInterface { - $client = $this->clientRepository->getClientEntity($clientId); - - if ($client instanceof ClientEntityInterface === false) { - $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); - throw OAuthServerException::invalidClient($request); - } + $client = parent::getClientEntityOrFail($clientId, $request); if ($this->supportsGrantType($client, $this->getIdentifier()) === false) { throw OAuthServerException::unauthorizedClient(); @@ -205,29 +135,6 @@ protected function supportsGrantType(ClientEntityInterface $client, string $gran || $client->supportsGrantType($grantType) === true; } - /** - * Gets the client credentials from the request from the request body or - * the Http Basic Authorization header - * - * @return array{0:non-empty-string,1:string} - * - * @throws OAuthServerException - */ - protected function getClientCredentials(ServerRequestInterface $request): array - { - [$basicAuthUser, $basicAuthPassword] = $this->getBasicAuthCredentials($request); - - $clientId = $this->getRequestParameter('client_id', $request, $basicAuthUser); - - if ($clientId === null) { - throw OAuthServerException::invalidRequest('client_id'); - } - - $clientSecret = $this->getRequestParameter('client_secret', $request, $basicAuthPassword); - - return [$clientId, $clientSecret ?? '']; - } - /** * Validate redirectUri from the request. If a redirect URI is provided * ensure it matches what is pre-registered @@ -289,123 +196,6 @@ private function convertScopesQueryStringToArray(string $scopes): array return array_filter(explode(self::SCOPE_DELIMITER_STRING, trim($scopes)), static fn ($scope) => $scope !== ''); } - /** - * Parse request parameter. - * - * @param array $request - * - * @return non-empty-string|null - * - * @throws OAuthServerException - */ - private static function parseParam(string $parameter, array $request, ?string $default = null): ?string - { - $value = $request[$parameter] ?? ''; - - if (is_scalar($value)) { - $value = trim((string) $value); - } else { - throw OAuthServerException::invalidRequest($parameter); - } - - if ($value === '') { - $value = $default === null ? null : trim($default); - - if ($value === '') { - $value = null; - } - } - - return $value; - } - - /** - * Retrieve request parameter. - * - * @return non-empty-string|null - * - * @throws OAuthServerException - */ - protected function getRequestParameter(string $parameter, ServerRequestInterface $request, ?string $default = null): ?string - { - return self::parseParam($parameter, (array) $request->getParsedBody(), $default); - } - - /** - * Retrieve HTTP Basic Auth credentials with the Authorization header - * of a request. First index of the returned array is the username, - * second is the password (so list() will work). If the header does - * not exist, or is otherwise an invalid HTTP Basic header, return - * [null, null]. - * - * @return array{0:non-empty-string,1:string}|array{0:null,1:null} - */ - protected function getBasicAuthCredentials(ServerRequestInterface $request): array - { - if (!$request->hasHeader('Authorization')) { - return [null, null]; - } - - $header = $request->getHeader('Authorization')[0]; - if (stripos($header, 'Basic ') !== 0) { - return [null, null]; - } - - $decoded = base64_decode(substr($header, 6), true); - - if ($decoded === false) { - return [null, null]; - } - - if (str_contains($decoded, ':') === false) { - return [null, null]; // HTTP Basic header without colon isn't valid - } - - [$username, $password] = explode(':', $decoded, 2); - - if ($username === '') { - return [null, null]; - } - - return [$username, $password]; - } - - /** - * Retrieve query string parameter. - * - * @return non-empty-string|null - * - * @throws OAuthServerException - */ - protected function getQueryStringParameter(string $parameter, ServerRequestInterface $request, ?string $default = null): ?string - { - return self::parseParam($parameter, $request->getQueryParams(), $default); - } - - /** - * Retrieve cookie parameter. - * - * @return non-empty-string|null - * - * @throws OAuthServerException - */ - protected function getCookieParameter(string $parameter, ServerRequestInterface $request, ?string $default = null): ?string - { - return self::parseParam($parameter, $request->getCookieParams(), $default); - } - - /** - * Retrieve server parameter. - * - * @return non-empty-string|null - * - * @throws OAuthServerException - */ - protected function getServerParameter(string $parameter, ServerRequestInterface $request, ?string $default = null): ?string - { - return self::parseParam($parameter, $request->getServerParams(), $default); - } - /** * Issue an access token. * diff --git a/src/Grant/RefreshTokenGrant.php b/src/Grant/RefreshTokenGrant.php index 34e3f20b4..06a177df3 100644 --- a/src/Grant/RefreshTokenGrant.php +++ b/src/Grant/RefreshTokenGrant.php @@ -15,7 +15,6 @@ namespace League\OAuth2\Server\Grant; use DateInterval; -use Exception; use League\OAuth2\Server\Exception\OAuthServerException; use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; use League\OAuth2\Server\RequestAccessTokenEvent; @@ -26,8 +25,6 @@ use function implode; use function in_array; -use function json_decode; -use function time; /** * Refresh token grant. @@ -107,28 +104,7 @@ protected function validateOldRefreshToken(ServerRequestInterface $request, stri $encryptedRefreshToken = $this->getRequestParameter('refresh_token', $request) ?? throw OAuthServerException::invalidRequest('refresh_token'); - // Validate refresh token - try { - $refreshToken = $this->decrypt($encryptedRefreshToken); - } catch (Exception $e) { - throw OAuthServerException::invalidRefreshToken('Cannot decrypt the refresh token', $e); - } - - $refreshTokenData = json_decode($refreshToken, true); - if ($refreshTokenData['client_id'] !== $clientId) { - $this->getEmitter()->emit(new RequestEvent(RequestEvent::REFRESH_TOKEN_CLIENT_FAILED, $request)); - throw OAuthServerException::invalidRefreshToken('Token is not linked to client'); - } - - if ($refreshTokenData['expire_time'] < time()) { - throw OAuthServerException::invalidRefreshToken('Token has expired'); - } - - if ($this->refreshTokenRepository->isRefreshTokenRevoked($refreshTokenData['refresh_token_id']) === true) { - throw OAuthServerException::invalidRefreshToken('Token has been revoked'); - } - - return $refreshTokenData; + return $this->validateEncryptedRefreshToken($request, $encryptedRefreshToken, $clientId); } /** diff --git a/src/Handlers/AbstractTokenHandler.php b/src/Handlers/AbstractTokenHandler.php new file mode 100644 index 000000000..b0e519692 --- /dev/null +++ b/src/Handlers/AbstractTokenHandler.php @@ -0,0 +1,107 @@ +publicKey = $publicKey; + } + + public function setJwtValidator(JwtValidatorInterface $jwtValidator): void + { + $this->jwtValidator = $jwtValidator; + } + + protected function getJwtValidator(): JwtValidatorInterface + { + if ($this->jwtValidator instanceof JwtValidatorInterface === false) { + $this->jwtValidator = new BearerTokenValidator($this->accessTokenRepository); + } + + if ($this->jwtValidator instanceof BearerTokenValidator === true) { + $this->jwtValidator->setPublicKey($this->publicKey); + } + + return $this->jwtValidator; + } + + /** + * @return array{0:non-empty-string, 1:array}|array{0:null, 1:null} + * + * @throws OAuthServerException + */ + protected function validateToken( + ServerRequestInterface $request, + ClientEntityInterface $client + ): array { + $token = $this->getRequestParameter('token', $request) + ?? throw OAuthServerException::invalidRequest('token'); + + $tokenTypeHint = $this->getRequestParameter('token_type_hint', $request, 'access_token'); + + if ($tokenTypeHint === 'refresh_token') { + return $this->validateRefreshToken($request, $token, $client) + ?? $this->validateAccessToken($request, $token, $client) + ?? [null, null]; + } + + return $this->validateAccessToken($request, $token, $client) + ?? $this->validateRefreshToken($request, $token, $client) + ?? [null, null]; + } + + /** + * @return array{0:non-empty-string, 1:array}|null + */ + protected function validateRefreshToken( + ServerRequestInterface $request, + string $refreshToken, + ClientEntityInterface $client + ): ?array { + try { + return [ + 'refresh_token', + $this->validateEncryptedRefreshToken($request, $refreshToken, $client->getIdentifier()), + ]; + } catch (Throwable) { + return null; + } + } + + /** + * @param non-empty-string $accessToken + * + * @return array{0:non-empty-string, 1:array}|null + */ + protected function validateAccessToken( + ServerRequestInterface $request, + string $accessToken, + ClientEntityInterface $client + ): ?array { + try { + return [ + 'access_token', + $this->getJwtValidator()->validateJwt($request, $accessToken, $client->getIdentifier()), + ]; + } catch (Throwable) { + return null; + } + } +} diff --git a/src/Handlers/TokenHandlerInterface.php b/src/Handlers/TokenHandlerInterface.php new file mode 100644 index 000000000..7165eea86 --- /dev/null +++ b/src/Handlers/TokenHandlerInterface.php @@ -0,0 +1,29 @@ +responseType === null ? new IntrospectionResponse() : clone $this->responseType; + } + + public function setResponseType(IntrospectionResponseTypeInterface $responseType): void + { + $this->responseType = $responseType; + } + + public function respondToRequest(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $client = $this->validateClient($request); + [$tokenType, $token] = $this->validateToken($request, $client); + + $responseType = $this->getResponseType(); + + if ($tokenType !== null && $token !== null) { + $responseType->setActive(true); + $responseType->setTokenType($tokenType); + $responseType->setToken($token); + } else { + $responseType->setActive(false); + } + + return $responseType->generateHttpResponse($response); + } +} diff --git a/src/Handlers/TokenRevocationHandler.php b/src/Handlers/TokenRevocationHandler.php new file mode 100644 index 000000000..2480afa87 --- /dev/null +++ b/src/Handlers/TokenRevocationHandler.php @@ -0,0 +1,31 @@ +validateClient($request); + [$tokenType, $token] = $this->validateToken($request, $client); + + if ($tokenType !== null && $token !== null) { + if ($tokenType === 'refresh_token') { + $this->refreshTokenRepository->revokeRefreshToken($token['refresh_token_id']); + $this->accessTokenRepository->revokeAccessToken($token['access_token_id']); + } elseif ($tokenType === 'access_token') { + $this->accessTokenRepository->revokeAccessToken($token['jti']); + } + } + + return $response + ->withStatus(200) + ->withHeader('pragma', 'no-cache') + ->withHeader('cache-control', 'no-store'); + } +} diff --git a/src/ResponseTypes/IntrospectionResponse.php b/src/ResponseTypes/IntrospectionResponse.php new file mode 100644 index 000000000..f1ac23201 --- /dev/null +++ b/src/ResponseTypes/IntrospectionResponse.php @@ -0,0 +1,125 @@ + + */ + protected ?array $token = null; + + public function setActive(bool $active): void + { + $this->active = $active; + } + + /** + * {@inheritdoc} + */ + public function setTokenType(string $tokenType): void + { + $this->tokenType = $tokenType; + } + + /** + * {@inheritdoc} + */ + public function setToken(array $token): void + { + $this->token = $token; + } + + public function generateHttpResponse(ResponseInterface $response): ResponseInterface + { + $params = [ + 'active' => $this->active, + ]; + + if ($this->active === true && $this->tokenType !== null && $this->token !== null) { + $params = array_merge( + $params, + $this->parseParams($this->tokenType, $this->token), + $this->getExtraParams($this->tokenType, $this->token) + ); + } + + $params = json_encode($params, flags: JSON_THROW_ON_ERROR); + + $response = $response + ->withStatus(200) + ->withHeader('pragma', 'no-cache') + ->withHeader('cache-control', 'no-store') + ->withHeader('content-type', 'application/json; charset=UTF-8'); + + $response->getBody()->write($params); + + return $response; + } + + /** + * @param non-empty-string $tokenType + * @param array $token + * + * @return array + */ + protected function parseParams(string $tokenType, array $token): array + { + if ($tokenType === 'access_token') { + return array_filter([ + 'scope' => $token['scope'] ?? implode(' ', $token['scopes'] ?? []), + 'client_id' => $token['client_id'] ?? $token['aud'][0] ?? null, + 'username' => $token['username'] ?? null, + 'token_type' => 'Bearer', + 'exp' => isset($token['exp']) ? $this->convertTimestamp($token['exp']) : null, + 'iat' => isset($token['iat']) ? $this->convertTimestamp($token['iat']) : null, + 'nbf' => isset($token['nbf']) ? $this->convertTimestamp($token['nbf']) : null, + 'sub' => $token['sub'] ?? null, + 'aud' => $token['aud'] ?? null, + 'iss' => $token['iss'] ?? null, + 'jti' => $token['jti'] ?? null, + ], fn ($value) => !is_null($value)); + } elseif ($tokenType === 'refresh_token') { + return array_filter([ + 'scope' => implode(' ', $token['scopes'] ?? []), + 'client_id' => $token['client_id'] ?? null, + 'exp' => isset($token['expire_time']) ? $this->convertTimestamp($token['expire_time']) : null, + 'sub' => $token['user_id'] ?? null, + 'jti' => $token['refresh_token_id'] ?? null, + ], fn ($value) => !is_null($value)); + } else { + return []; + } + } + + protected function convertTimestamp(int|float|string|DateTimeInterface $value): int + { + return match (true) { + $value instanceof DateTimeInterface => $value->getTimestamp(), + default => intval($value), + }; + } + + /** + * @param non-empty-string $tokenType + * @param array $token + * + * @return array + */ + protected function getExtraParams(string $tokenType, array $token): array + { + return []; + } +} diff --git a/src/ResponseTypes/IntrospectionResponseTypeInterface.php b/src/ResponseTypes/IntrospectionResponseTypeInterface.php new file mode 100644 index 000000000..79839869d --- /dev/null +++ b/src/ResponseTypes/IntrospectionResponseTypeInterface.php @@ -0,0 +1,24 @@ + $token + */ + public function setToken(array $token): void; + + public function generateHttpResponse(ResponseInterface $response): ResponseInterface; +} diff --git a/src/TokenServer.php b/src/TokenServer.php new file mode 100644 index 000000000..8d8bfd404 --- /dev/null +++ b/src/TokenServer.php @@ -0,0 +1,94 @@ +publicKey = $publicKey; + } + + public function setTokenRevocationHandler(TokenHandlerInterface $handler): void + { + $this->tokenRevocationHandler = $handler; + } + + public function setTokenIntrospectionHandler(TokenHandlerInterface $handler): void + { + $this->tokenIntrospectionHandler = $handler; + } + + protected function getTokenRevocationHandler(): TokenHandlerInterface + { + $this->tokenRevocationHandler ??= new TokenRevocationHandler(); + + $this->tokenRevocationHandler->setClientRepository($this->clientRepository); + $this->tokenRevocationHandler->setAccessTokenRepository($this->accessTokenRepository); + $this->tokenRevocationHandler->setRefreshTokenRepository($this->refreshTokenRepository); + $this->tokenRevocationHandler->setPublicKey($this->publicKey); + $this->tokenRevocationHandler->setEmitter($this->getEmitter()); + $this->tokenRevocationHandler->setEncryptionKey($this->encryptionKey); + + return $this->tokenRevocationHandler; + } + + protected function getTokenIntrospectionHandler(): TokenHandlerInterface + { + $this->tokenIntrospectionHandler ??= new TokenIntrospectionHandler(); + + $this->tokenIntrospectionHandler->setClientRepository($this->clientRepository); + $this->tokenIntrospectionHandler->setAccessTokenRepository($this->accessTokenRepository); + $this->tokenIntrospectionHandler->setRefreshTokenRepository($this->refreshTokenRepository); + $this->tokenIntrospectionHandler->setPublicKey($this->publicKey); + $this->tokenIntrospectionHandler->setEmitter($this->getEmitter()); + $this->tokenIntrospectionHandler->setEncryptionKey($this->encryptionKey); + + return $this->tokenIntrospectionHandler; + } + + public function respondToTokenRevocationRequest( + ServerRequestInterface $request, + ResponseInterface $response + ): ResponseInterface { + return $this->getTokenRevocationHandler()->respondToRequest($request, $response); + } + + public function respondToTokenIntrospectionRequest( + ServerRequestInterface $request, + ResponseInterface $response + ): ResponseInterface { + return $this->getTokenIntrospectionHandler()->respondToRequest($request, $response); + } +} diff --git a/tests/Handlers/AbstractTokenHandlerTest.php b/tests/Handlers/AbstractTokenHandlerTest.php new file mode 100644 index 000000000..d2ac94396 --- /dev/null +++ b/tests/Handlers/AbstractTokenHandlerTest.php @@ -0,0 +1,419 @@ +setEncryptionKey(base64_encode(random_bytes(36))); + } + + public function testSetJwtValidator(): void + { + $request = new ServerRequest(); + $accessToken = 'abcdef'; + $client = new ClientEntity(); + $client->setIdentifier('client1'); + + $jwtValidator = $this->createMock(JwtValidatorInterface::class); + $jwtValidator + ->expects(self::once()) + ->method('validateJwt') + ->with($request, 'abcdef', 'client1') + ->willReturn(['foo' => 'bar']); + + $handler = $this->getAbstractTokenHandler(); + $handler->setJwtValidator($jwtValidator); + + $result = (fn () => $this->validateAccessToken($request, $accessToken, $client))->call($handler); + + self::assertSame(['access_token', ['foo' => 'bar']], $result); + } + + public function testValidateToken(): void + { + $client = new ClientEntity(); + $request = new ServerRequest(); + + try { + (fn () => $this->validateToken($request, $client))->call($this->getAbstractTokenHandlerWithToken()); + + self::fail('The expected exception was not thrown'); + } catch (OAuthServerException $e) { + self::assertSame('invalid_request', $e->getErrorType()); + } + + $request = (new ServerRequest())->withParsedBody(['token' => 'token1']); + + self::assertSame(['access_token', ['foo' => 'bar']], (fn () => $this->validateToken($request, $client))->call( + $this->getAbstractTokenHandlerWithToken(accessToken: ['foo' => 'bar'], refreshToken: ['bar' => 'foo']) + )); + self::assertSame(['access_token', ['foo' => 'bar']], (fn () => $this->validateToken($request, $client))->call( + $this->getAbstractTokenHandlerWithToken(accessToken: ['foo' => 'bar']) + )); + self::assertSame(['refresh_token', ['bar' => 'foo']], (fn () => $this->validateToken($request, $client))->call( + $this->getAbstractTokenHandlerWithToken(refreshToken: ['bar' => 'foo']) + )); + self::assertSame([null, null], (fn () => $this->validateToken($request, $client))->call( + $this->getAbstractTokenHandlerWithToken() + )); + + $request = (new ServerRequest())->withParsedBody(['token' => 'token1', 'token_type_hint' => 'access_token']); + + self::assertSame(['access_token', ['foo' => 'bar']], (fn () => $this->validateToken($request, $client))->call( + $this->getAbstractTokenHandlerWithToken(accessToken: ['foo' => 'bar'], refreshToken: ['bar' => 'foo']) + )); + self::assertSame(['refresh_token', ['bar' => 'foo']], (fn () => $this->validateToken($request, $client))->call( + $this->getAbstractTokenHandlerWithToken(refreshToken: ['bar' => 'foo']) + )); + self::assertSame([null, null], (fn () => $this->validateToken($request, $client))->call( + $this->getAbstractTokenHandlerWithToken() + )); + + $request = (new ServerRequest())->withParsedBody(['token' => 'token1', 'token_type_hint' => 'refresh_token']); + + self::assertSame(['refresh_token', ['bar' => 'foo']], (fn () => $this->validateToken($request, $client))->call( + $this->getAbstractTokenHandlerWithToken(accessToken: ['foo' => 'bar'], refreshToken: ['bar' => 'foo']) + )); + self::assertSame(['access_token', ['foo' => 'bar']], (fn () => $this->validateToken($request, $client))->call( + $this->getAbstractTokenHandlerWithToken(accessToken: ['foo' => 'bar']) + )); + self::assertSame([null, null], (fn () => $this->validateToken($request, $client))->call( + $this->getAbstractTokenHandlerWithToken() + )); + } + + public function testValidateAccessToken(): void + { + $accessTokenRepository = $this->createMock(AccessTokenRepositoryInterface::class); + $accessTokenRepository + ->expects(self::once()) + ->method('isAccessTokenRevoked') + ->with('access1') + ->willReturn(false); + + $handler = $this->getAbstractTokenHandler(); + $handler->setAccessTokenRepository($accessTokenRepository); + + $request = new ServerRequest(); + $expireTime = time() + 1000; + $accessToken = $this->getJwtToken(fn (Builder $builder) => + $builder->permittedFor('client1') + ->relatedTo('user1') + ->identifiedBy('access1') + ->expiresAt((new DateTimeImmutable())->setTimestamp($expireTime)) + ->withClaim('foo', 'bar')); + $client = new ClientEntity(); + $client->setIdentifier('client1'); + + /** @var array{0:non-empty-string, 1:array} $result */ + $result = (fn () => $this->validateAccessToken($request, $accessToken, $client))->call($handler); + $result[1]['exp'] = $result[1]['exp']->getTimestamp(); + + self::assertSame(['access_token', [ + 'aud' => ['client1'], + 'sub' => 'user1', + 'jti' => 'access1', + 'exp' => $expireTime, + 'foo' => 'bar', + ]], $result); + } + + public function testValidateAccessTokenIsRevoked(): void + { + $accessTokenRepository = $this->createMock(AccessTokenRepositoryInterface::class); + $accessTokenRepository + ->expects(self::once()) + ->method('isAccessTokenRevoked') + ->with('access1') + ->willReturn(true); + + $handler = $this->getAbstractTokenHandler(); + $handler->setAccessTokenRepository($accessTokenRepository); + + $request = new ServerRequest(); + $expireTime = time() + 1000; + $accessToken = $this->getJwtToken(fn (Builder $builder) => + $builder->permittedFor('client1') + ->relatedTo('user1') + ->identifiedBy('access1') + ->expiresAt((new DateTimeImmutable())->setTimestamp($expireTime))); + $client = new ClientEntity(); + $client->setIdentifier('client1'); + + $result = (fn () => $this->validateAccessToken($request, $accessToken, $client))->call($handler); + + self::assertNull($result); + } + + public function testValidateAccessTokenIsExpired(): void + { + $accessTokenRepository = $this->createMock(AccessTokenRepositoryInterface::class); + $accessTokenRepository->expects(self::never())->method('isAccessTokenRevoked'); + + $handler = $this->getAbstractTokenHandler(); + $handler->setAccessTokenRepository($accessTokenRepository); + + $request = new ServerRequest(); + $expireTime = time() - 1000; + $accessToken = $this->getJwtToken(fn (Builder $builder) => + $builder->permittedFor('client1') + ->relatedTo('user1') + ->identifiedBy('access1') + ->expiresAt((new DateTimeImmutable())->setTimestamp($expireTime))); + $client = new ClientEntity(); + $client->setIdentifier('client1'); + + $result = (fn () => $this->validateAccessToken($request, $accessToken, $client))->call($handler); + + self::assertNull($result); + } + + public function testValidateAccessTokenWithMismatchClient(): void + { + $accessTokenRepository = $this->createMock(AccessTokenRepositoryInterface::class); + $accessTokenRepository->expects(self::never())->method('isAccessTokenRevoked'); + + $handler = $this->getAbstractTokenHandler(); + $handler->setAccessTokenRepository($accessTokenRepository); + + $request = new ServerRequest(); + $expireTime = time() + 1000; + $accessToken = $this->getJwtToken(fn (Builder $builder) => + $builder->permittedFor('client2') + ->relatedTo('user1') + ->identifiedBy('access1') + ->expiresAt((new DateTimeImmutable())->setTimestamp($expireTime))); + $client = new ClientEntity(); + $client->setIdentifier('client1'); + + $result = (fn () => $this->validateAccessToken($request, $accessToken, $client))->call($handler); + + self::assertNull($result); + } + + public function testValidateAccessTokenWithInvalidToken(): void + { + $accessTokenRepository = $this->createMock(AccessTokenRepositoryInterface::class); + $accessTokenRepository->expects(self::never())->method('isAccessTokenRevoked'); + + $handler = $this->getAbstractTokenHandler(); + $handler->setAccessTokenRepository($accessTokenRepository); + + $request = new ServerRequest(); + $accessToken = 'abcdef'; + $client = new ClientEntity(); + $client->setIdentifier('client1'); + + $result = (fn () => $this->validateAccessToken($request, $accessToken, $client))->call($handler); + + self::assertNull($result); + } + + public function testValidateRefreshToken(): void + { + $refreshTokenRepository = $this->createMock(RefreshTokenRepositoryInterface::class); + $refreshTokenRepository + ->expects(self::once()) + ->method('isRefreshTokenRevoked') + ->with('refresh1') + ->willReturn(false); + + $handler = $this->getAbstractTokenHandler(); + $handler->setRefreshTokenRepository($refreshTokenRepository); + + $request = new ServerRequest(); + $refreshToken = $this->encrypt(json_encode([ + 'refresh_token_id' => 'refresh1', + 'expire_time' => $expireTime = time() + 1000, + 'client_id' => 'client1', + 'foo' => 'bar', + ], flags: JSON_THROW_ON_ERROR)); + $client = new ClientEntity(); + $client->setIdentifier('client1'); + + $result = (fn () => $this->validateRefreshToken($request, $refreshToken, $client))->call($handler); + + self::assertSame(['refresh_token', [ + 'refresh_token_id' => 'refresh1', + 'expire_time' => $expireTime, + 'client_id' => 'client1', + 'foo' => 'bar', + ]], $result); + } + + public function testValidateRefreshTokenIsRevoked(): void + { + $refreshTokenRepository = $this->createMock(RefreshTokenRepositoryInterface::class); + $refreshTokenRepository + ->expects(self::once()) + ->method('isRefreshTokenRevoked') + ->with('refresh1') + ->willReturn(true); + + $handler = $this->getAbstractTokenHandler(); + $handler->setRefreshTokenRepository($refreshTokenRepository); + + $request = new ServerRequest(); + $refreshToken = $this->encrypt(json_encode([ + 'refresh_token_id' => 'refresh1', + 'expire_time' => time() + 1000, + 'client_id' => 'client1', + ], flags: JSON_THROW_ON_ERROR)); + $client = new ClientEntity(); + $client->setIdentifier('client1'); + + $result = (fn () => $this->validateRefreshToken($request, $refreshToken, $client))->call($handler); + + self::assertNull($result); + } + + public function testValidateRefreshTokenIsExpired(): void + { + $refreshTokenRepository = $this->createMock(RefreshTokenRepositoryInterface::class); + $refreshTokenRepository->expects(self::never())->method('isRefreshTokenRevoked'); + + $handler = $this->getAbstractTokenHandler(); + $handler->setRefreshTokenRepository($refreshTokenRepository); + + $request = new ServerRequest(); + $refreshToken = $this->encrypt(json_encode([ + 'refresh_token_id' => 'refresh1', + 'expire_time' => time() - 1000, + 'client_id' => 'client1', + ], flags: JSON_THROW_ON_ERROR)); + $client = new ClientEntity(); + $client->setIdentifier('client1'); + + $result = (fn () => $this->validateRefreshToken($request, $refreshToken, $client))->call($handler); + + self::assertNull($result); + } + + public function testValidateRefreshTokenWithMismatchClient(): void + { + $refreshTokenRepository = $this->createMock(RefreshTokenRepositoryInterface::class); + $refreshTokenRepository->expects(self::never())->method('isRefreshTokenRevoked'); + + $handler = $this->getAbstractTokenHandler(); + $handler->setRefreshTokenRepository($refreshTokenRepository); + + $request = new ServerRequest(); + $refreshToken = $this->encrypt(json_encode([ + 'refresh_token_id' => 'refresh1', + 'expire_time' => time() + 1000, + 'client_id' => 'client2', + ], flags: JSON_THROW_ON_ERROR)); + $client = new ClientEntity(); + $client->setIdentifier('client1'); + + $result = (fn () => $this->validateRefreshToken($request, $refreshToken, $client))->call($handler); + + self::assertNull($result); + } + + public function testValidateRefreshTokenWithInvalidToken(): void + { + $refreshTokenRepository = $this->createMock(RefreshTokenRepositoryInterface::class); + $refreshTokenRepository->expects(self::never())->method('isRefreshTokenRevoked'); + + $handler = $this->getAbstractTokenHandler(); + $handler->setRefreshTokenRepository($refreshTokenRepository); + + $request = new ServerRequest(); + $refreshToken = 'abcdef'; + $client = new ClientEntity(); + $client->setIdentifier('client1'); + + $result = (fn () => $this->validateRefreshToken($request, $refreshToken, $client))->call($handler); + + self::assertNull($result); + } + + /** + * @return AbstractTokenHandler&MockObject + */ + private function getAbstractTokenHandler(): MockObject + { + $handler = $this->getMockBuilder(AbstractTokenHandler::class)->onlyMethods(['respondToRequest'])->getMock(); + + $handler->setEncryptionKey($this->encryptionKey); + $handler->setPublicKey(new CryptKey('file://' . __DIR__ . '/../Stubs/public.key')); + + return $handler; + } + + /** + * @param array|null $accessToken + * @param array|null $refreshToken + * + * @return AbstractTokenHandler&MockObject + */ + private function getAbstractTokenHandlerWithToken(?array $accessToken = null, ?array $refreshToken = null): MockObject + { + $handler = $this->getMockBuilder(AbstractTokenHandler::class) + ->onlyMethods(['respondToRequest', 'validateAccessToken', 'validateRefreshToken']) + ->getMock(); + + $handler->method('validateAccessToken') + ->willReturn($accessToken === null ? null : ['access_token', $accessToken]); + + $handler->method('validateRefreshToken') + ->willReturn($refreshToken === null ? null : ['refresh_token', $refreshToken]); + + return $handler; + } + + /** + * @param Closure(Builder): Builder $withBuilder + * + * @return non-empty-string + */ + private function getJwtToken(Closure $withBuilder): string + { + $privateKey = new CryptKey('file://' . __DIR__ . '/../Stubs/private.key'); + + $contents = $privateKey->getKeyContents(); + + if ($contents === '') { + $contents = 'empty'; + } + + $configuration = Configuration::forAsymmetricSigner( + new Sha256(), + InMemory::plainText($contents, $privateKey->getPassPhrase() ?? ''), + InMemory::plainText('empty', 'empty') + ); + + return $withBuilder($configuration->builder()) + ->getToken($configuration->signer(), $configuration->signingKey()) + ->toString(); + } +} diff --git a/tests/Handlers/TokenIntrospectionHandlerTest.php b/tests/Handlers/TokenIntrospectionHandlerTest.php new file mode 100644 index 000000000..4a5be8194 --- /dev/null +++ b/tests/Handlers/TokenIntrospectionHandlerTest.php @@ -0,0 +1,188 @@ +setConfidential(); + $client->setIdentifier('client1'); + + $clientRepository = $this->createMock(ClientRepositoryInterface::class); + $clientRepository->expects(self::once()) + ->method('getClientEntity') + ->with('client1') + ->willReturn($client); + $clientRepository + ->expects(self::once()) + ->method('validateClient') + ->with('client1', 'secret1', null) + ->willReturn(true); + + $request = (new ServerRequest())->withParsedBody([ + 'client_id' => 'client1', + 'client_secret' => 'secret1', + 'token' => 'token1', + ]); + + $handler = $this->getMockBuilder(TokenIntrospectionHandler::class)->onlyMethods(['validateAccessToken'])->getMock(); + $handler->setClientRepository($clientRepository); + $handler->expects(self::once()) + ->method('validateAccessToken') + ->with($request, 'token1', $client) + ->willReturn(['access_token', ['jti' => 'access1']]); + + $response = $handler->respondToRequest($request, new Response()); + $response->getBody()->rewind(); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('application/json; charset=UTF-8', $response->getHeaderLine('Content-Type')); + self::assertSame([ + 'active' => true, + 'scope' => '', + 'token_type' => 'Bearer', + 'jti' => 'access1', + ], json_decode($response->getBody()->getContents(), true)); + } + + public function testRespondToRequestForRefreshToken(): void + { + $client = new ClientEntity(); + $client->setConfidential(); + $client->setIdentifier('client1'); + + $clientRepository = $this->createMock(ClientRepositoryInterface::class); + $clientRepository->expects(self::once()) + ->method('getClientEntity') + ->with('client1') + ->willReturn($client); + $clientRepository + ->expects(self::once()) + ->method('validateClient') + ->with('client1', 'secret1', null) + ->willReturn(true); + + $request = (new ServerRequest())->withParsedBody([ + 'client_id' => 'client1', + 'client_secret' => 'secret1', + 'token' => 'token1', + ]); + + $handler = $this->getMockBuilder(TokenIntrospectionHandler::class)->onlyMethods(['validateRefreshToken'])->getMock(); + $handler->setClientRepository($clientRepository); + $handler->expects(self::once()) + ->method('validateRefreshToken') + ->with($request, 'token1', $client) + ->willReturn(['refresh_token', ['refresh_token_id' => 'refresh1']]); + + $response = $handler->respondToRequest($request, new Response()); + $response->getBody()->rewind(); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('application/json; charset=UTF-8', $response->getHeaderLine('Content-Type')); + self::assertSame([ + 'active' => true, + 'scope' => '', + 'jti' => 'refresh1', + ], json_decode($response->getBody()->getContents(), true)); + } + + public function testRespondToRequestForInvalidToken(): void + { + $client = new ClientEntity(); + $client->setConfidential(); + $client->setIdentifier('client1'); + + $clientRepository = $this->createMock(ClientRepositoryInterface::class); + $clientRepository->expects(self::once()) + ->method('getClientEntity') + ->with('client1') + ->willReturn($client); + $clientRepository + ->expects(self::once()) + ->method('validateClient') + ->with('client1', 'secret1', null) + ->willReturn(true); + + $request = (new ServerRequest())->withParsedBody([ + 'client_id' => 'client1', + 'client_secret' => 'secret1', + 'token' => 'token1', + ]); + + $handler = $this->getMockBuilder(TokenIntrospectionHandler::class) + ->onlyMethods(['validateAccessToken', 'validateRefreshToken'])->getMock(); + $handler->setClientRepository($clientRepository); + $handler->expects(self::once()) + ->method('validateAccessToken') + ->with($request, 'token1', $client) + ->willReturn(null); + $handler->expects(self::once()) + ->method('validateRefreshToken') + ->with($request, 'token1', $client) + ->willReturn(null); + + $response = $handler->respondToRequest($request, new Response()); + $response->getBody()->rewind(); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('application/json; charset=UTF-8', $response->getHeaderLine('Content-Type')); + self::assertSame(['active' => false], json_decode($response->getBody()->getContents(), true)); + } + + public function testSetResponseType(): void + { + $client = new ClientEntity(); + $client->setConfidential(); + $client->setIdentifier('client1'); + + $clientRepository = $this->createMock(ClientRepositoryInterface::class); + $clientRepository->expects(self::once()) + ->method('getClientEntity') + ->with('client1') + ->willReturn($client); + $clientRepository + ->expects(self::once()) + ->method('validateClient') + ->with('client1', 'secret1', null) + ->willReturn(true); + + $request = (new ServerRequest())->withParsedBody([ + 'client_id' => 'client1', + 'client_secret' => 'secret1', + 'token' => 'token1', + ]); + + $response = new Response(); + + $responseType = $this->createMock(IntrospectionResponseTypeInterface::class); + $responseType->expects(self::once())->method('setActive')->with(true); + $responseType->expects(self::once())->method('setTokenType')->with('foo'); + $responseType->expects(self::once())->method('setToken')->with(['bar' => 'baz']); + $responseType->expects(self::once())->method('generateHttpResponse')->with($response)->willReturnArgument(0); + + $handler = $this->getMockBuilder(TokenIntrospectionHandler::class)->onlyMethods(['validateToken'])->getMock(); + $handler->setClientRepository($clientRepository); + $handler->setResponseType($responseType); + $handler->expects(self::once()) + ->method('validateToken') + ->with($request, $client) + ->willReturn(['foo', ['bar' => 'baz']]); + + $result = $handler->respondToRequest($request, $response); + + self::assertSame($response, $result); + } +} diff --git a/tests/Handlers/TokenRevocationHandlerTest.php b/tests/Handlers/TokenRevocationHandlerTest.php new file mode 100644 index 000000000..a34063577 --- /dev/null +++ b/tests/Handlers/TokenRevocationHandlerTest.php @@ -0,0 +1,150 @@ +setConfidential(); + $client->setIdentifier('client1'); + + $clientRepository = $this->createMock(ClientRepositoryInterface::class); + $clientRepository->expects(self::once()) + ->method('getClientEntity') + ->with('client1') + ->willReturn($client); + $clientRepository + ->expects(self::once()) + ->method('validateClient') + ->with('client1', 'secret1', null) + ->willReturn(true); + + $accessTokenRepository = $this->createMock(AccessTokenRepositoryInterface::class); + $accessTokenRepository->expects(self::once())->method('revokeAccessToken')->with('access1'); + + $request = (new ServerRequest())->withParsedBody([ + 'client_id' => 'client1', + 'client_secret' => 'secret1', + 'token' => 'token1', + ]); + + $handler = $this->getMockBuilder(TokenRevocationHandler::class)->onlyMethods(['validateAccessToken'])->getMock(); + $handler->setClientRepository($clientRepository); + $handler->setAccessTokenRepository($accessTokenRepository); + $handler->expects(self::once()) + ->method('validateAccessToken') + ->with($request, 'token1', $client) + ->willReturn(['access_token', ['jti' => 'access1']]); + + $response = $handler->respondToRequest($request, new Response()); + $response->getBody()->rewind(); + + self::assertSame(200, $response->getStatusCode()); + } + + public function testRespondToRequestForRefreshToken(): void + { + $client = new ClientEntity(); + $client->setConfidential(); + $client->setIdentifier('client1'); + + $clientRepository = $this->createMock(ClientRepositoryInterface::class); + $clientRepository->expects(self::once()) + ->method('getClientEntity') + ->with('client1') + ->willReturn($client); + $clientRepository + ->expects(self::once()) + ->method('validateClient') + ->with('client1', 'secret1', null) + ->willReturn(true); + + $accessTokenRepository = $this->createMock(AccessTokenRepositoryInterface::class); + $accessTokenRepository->expects(self::once())->method('revokeAccessToken')->with('access1'); + + $refreshTokenRepository = $this->createMock(RefreshTokenRepositoryInterface::class); + $refreshTokenRepository->expects(self::once())->method('revokeRefreshToken')->with('refresh1'); + + $request = (new ServerRequest())->withParsedBody([ + 'client_id' => 'client1', + 'client_secret' => 'secret1', + 'token' => 'token1', + ]); + + $handler = $this->getMockBuilder(TokenRevocationHandler::class)->onlyMethods(['validateRefreshToken'])->getMock(); + $handler->setClientRepository($clientRepository); + $handler->setAccessTokenRepository($accessTokenRepository); + $handler->setRefreshTokenRepository($refreshTokenRepository); + $handler->expects(self::once()) + ->method('validateRefreshToken') + ->with($request, 'token1', $client) + ->willReturn(['refresh_token', ['refresh_token_id' => 'refresh1', 'access_token_id' => 'access1']]); + + $response = $handler->respondToRequest($request, new Response()); + $response->getBody()->rewind(); + + self::assertSame(200, $response->getStatusCode()); + } + + public function testRespondToRequestForInvalidToken(): void + { + $client = new ClientEntity(); + $client->setConfidential(); + $client->setIdentifier('client1'); + + $clientRepository = $this->createMock(ClientRepositoryInterface::class); + $clientRepository->expects(self::once()) + ->method('getClientEntity') + ->with('client1') + ->willReturn($client); + $clientRepository + ->expects(self::once()) + ->method('validateClient') + ->with('client1', 'secret1', null) + ->willReturn(true); + + $accessTokenRepository = $this->createMock(AccessTokenRepositoryInterface::class); + $accessTokenRepository->expects(self::never())->method('revokeAccessToken'); + + $refreshTokenRepository = $this->createMock(RefreshTokenRepositoryInterface::class); + $refreshTokenRepository->expects(self::never())->method('revokeRefreshToken'); + + $request = (new ServerRequest())->withParsedBody([ + 'client_id' => 'client1', + 'client_secret' => 'secret1', + 'token' => 'token1', + ]); + + $handler = $this->getMockBuilder(TokenRevocationHandler::class) + ->onlyMethods(['validateAccessToken', 'validateRefreshToken'])->getMock(); + $handler->setClientRepository($clientRepository); + $handler->setAccessTokenRepository($accessTokenRepository); + $handler->setRefreshTokenRepository($refreshTokenRepository); + $handler->expects(self::once()) + ->method('validateAccessToken') + ->with($request, 'token1', $client) + ->willReturn(null); + $handler->expects(self::once()) + ->method('validateRefreshToken') + ->with($request, 'token1', $client) + ->willReturn(null); + + $response = $handler->respondToRequest($request, new Response()); + $response->getBody()->rewind(); + + self::assertSame(200, $response->getStatusCode()); + } +} diff --git a/tests/ResponseTypes/IntrospectionResponseTest.php b/tests/ResponseTypes/IntrospectionResponseTest.php new file mode 100644 index 000000000..19c325941 --- /dev/null +++ b/tests/ResponseTypes/IntrospectionResponseTest.php @@ -0,0 +1,136 @@ +setActive(true); + $responseType->setTokenType('access_token'); + $responseType->setToken([ + 'scopes' => ['scope1', 'scope2'], + 'aud' => ['client1'], + 'username' => 'username1', + 'exp' => (new DateTimeImmutable())->setTimestamp(123456), + 'iat' => 111111, + 'nbf' => '654321', + 'sub' => 'user1', + 'iss' => 'https://example.com', + 'jti' => 'token1', + ]); + + $response = $responseType->generateHttpResponse(new Response()); + $response->getBody()->rewind(); + + self::assertEquals(200, $response->getStatusCode()); + self::assertEquals('no-cache', $response->getHeader('pragma')[0]); + self::assertEquals('no-store', $response->getHeader('cache-control')[0]); + self::assertEquals('application/json; charset=UTF-8', $response->getHeader('content-type')[0]); + self::assertSame([ + 'active' => true, + 'scope' => 'scope1 scope2', + 'client_id' => 'client1', + 'username' => 'username1', + 'token_type' => 'Bearer', + 'exp' => 123456, + 'iat' => 111111, + 'nbf' => 654321, + 'sub' => 'user1', + 'aud' => ['client1'], + 'iss' => 'https://example.com', + 'jti' => 'token1', + ], json_decode($response->getBody()->getContents(), true)); + } + + public function testGenerateHttpResponseForRefreshToken(): void + { + $responseType = new IntrospectionResponse(); + $responseType->setActive(true); + $responseType->setTokenType('refresh_token'); + $responseType->setToken([ + 'scopes' => ['scope1', 'scope2'], + 'client_id' => 'client1', + 'expire_time' => (new DateTimeImmutable())->setTimestamp(123456), + 'user_id' => 'user1', + 'refresh_token_id' => 'token1', + ]); + + $response = $responseType->generateHttpResponse(new Response()); + $response->getBody()->rewind(); + + self::assertEquals(200, $response->getStatusCode()); + self::assertEquals('no-cache', $response->getHeader('pragma')[0]); + self::assertEquals('no-store', $response->getHeader('cache-control')[0]); + self::assertEquals('application/json; charset=UTF-8', $response->getHeader('content-type')[0]); + self::assertSame([ + 'active' => true, + 'scope' => 'scope1 scope2', + 'client_id' => 'client1', + 'exp' => 123456, + 'sub' => 'user1', + 'jti' => 'token1', + ], json_decode($response->getBody()->getContents(), true)); + } + + public function testGenerateHttpResponseForInactiveToken(): void + { + $responseType = new IntrospectionResponse(); + $responseType->setActive(false); + $responseType->setTokenType('access_token'); + $responseType->setToken([ + 'scopes' => ['scope1', 'scope2'], + 'client_id' => 'client1', + ]); + + $response = $responseType->generateHttpResponse(new Response()); + $response->getBody()->rewind(); + + self::assertEquals(200, $response->getStatusCode()); + self::assertEquals('no-cache', $response->getHeader('pragma')[0]); + self::assertEquals('no-store', $response->getHeader('cache-control')[0]); + self::assertEquals('application/json; charset=UTF-8', $response->getHeader('content-type')[0]); + self::assertSame([ + 'active' => false, + ], json_decode($response->getBody()->getContents(), true)); + } + + public function testGenerateHttpResponseWithExtraParams(): void + { + $responseType = new IntrospectionResponseWithParams(); + $responseType->setActive(true); + $responseType->setTokenType('access_token'); + $responseType->setToken([ + 'scopes' => ['scope1', 'scope2'], + 'client_id' => 'client1', + 'jti' => null, + 'extension' => 'extension1', + ]); + + $response = $responseType->generateHttpResponse(new Response()); + $response->getBody()->rewind(); + + self::assertEquals(200, $response->getStatusCode()); + self::assertEquals('no-cache', $response->getHeader('pragma')[0]); + self::assertEquals('no-store', $response->getHeader('cache-control')[0]); + self::assertEquals('application/json; charset=UTF-8', $response->getHeader('content-type')[0]); + self::assertSame([ + 'active' => true, + 'scope' => 'scope1 scope2', + 'client_id' => 'client1', + 'token_type' => 'Bearer', + 'foo' => 'bar', + 'extended' => 'extension1', + ], json_decode($response->getBody()->getContents(), true)); + } +} diff --git a/tests/ResponseTypes/IntrospectionResponseWithParams.php b/tests/ResponseTypes/IntrospectionResponseWithParams.php new file mode 100644 index 000000000..3b20e4d76 --- /dev/null +++ b/tests/ResponseTypes/IntrospectionResponseWithParams.php @@ -0,0 +1,18 @@ + 'bar', 'extended' => $token['extension']]; + } +} diff --git a/tests/TokenServerTest.php b/tests/TokenServerTest.php new file mode 100644 index 000000000..64e097a9f --- /dev/null +++ b/tests/TokenServerTest.php @@ -0,0 +1,124 @@ +setIdentifier('foo'); + + $clientRepository = $this->createMock(ClientRepositoryInterface::class); + $clientRepository->expects(self::once())->method('getClientEntity') + ->with('foo') + ->willReturn($client); + + $server = $this->getTokenServer($clientRepository); + + $request = (new ServerRequest())->withParsedBody([ + 'client_id' => 'foo', + 'client_secret' => 'bar', + 'token' => 'foobar', + ]); + + $result = $server->respondToTokenRevocationRequest($request, new Response()); + + self::assertSame(200, $result->getStatusCode()); + } + + public function testRespondToTokenIntrospectionRequest(): void + { + $client = new ClientEntity(); + $client->setIdentifier('foo'); + + $clientRepository = $this->createMock(ClientRepositoryInterface::class); + $clientRepository->expects(self::once())->method('getClientEntity') + ->with('foo') + ->willReturn($client); + + $server = $this->getTokenServer($clientRepository); + + $request = (new ServerRequest())->withParsedBody([ + 'client_id' => 'foo', + 'client_secret' => 'bar', + 'token' => 'foobar', + ]); + + $result = $server->respondToTokenIntrospectionRequest($request, new Response()); + $result->getBody()->rewind(); + + self::assertSame(200, $result->getStatusCode()); + self::assertSame('application/json; charset=UTF-8', $result->getHeaderLine('Content-Type')); + self::assertSame('{"active":false}', $result->getBody()->getContents()); + } + + public function testSetTokenRevocationHandler(): void + { + $server = $this->getTokenServer(); + + $request = $this->createMock(ServerRequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + + $revocationHandler = $this->getMockBuilder(TokenHandlerInterface::class)->getMock(); + $revocationHandler->expects(self::once())->method('respondToRequest') + ->with($request, $response) + ->willReturn($response); + + $server->setTokenRevocationHandler($revocationHandler); + + $result = $server->respondToTokenRevocationRequest($request, $response); + + self::assertSame($response, $result); + } + + public function testSetTokenIntrospectionHandler(): void + { + $server = $this->getTokenServer(); + + $request = $this->createMock(ServerRequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + + $introspectionHandler = $this->getMockBuilder(TokenHandlerInterface::class)->getMock(); + $introspectionHandler->expects(self::once())->method('respondToRequest') + ->with($request, $response) + ->willReturn($response); + + $server->setTokenIntrospectionHandler($introspectionHandler); + + $result = $server->respondToTokenIntrospectionRequest($request, $response); + + self::assertSame($response, $result); + } + + private function getTokenServer( + ?ClientRepositoryInterface $clientRepository = null, + ?AccessTokenRepositoryInterface $accessTokenRepository = null, + ?RefreshTokenRepositoryInterface $refreshTokenRepository = null + ): TokenServer { + return new TokenServer( + $clientRepository ?? $this->createMock(ClientRepositoryInterface::class), + $accessTokenRepository ?? $this->createMock(AccessTokenRepositoryInterface::class), + $refreshTokenRepository ?? $this->createMock(RefreshTokenRepositoryInterface::class), + 'file://' . __DIR__ . '/Stubs/public.key', + base64_encode(random_bytes(36)) + ); + } +}