-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Support token revocation and introspection #1473
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
base: master
Are you sure you want to change the base?
Changes from all commits
fbc0289
704a3cf
9555b91
3482d6f
68eded0
f52f03d
09d284b
70bf835
3c574f2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,282 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace League\OAuth2\Server; | ||
|
||
use Exception; | ||
use League\OAuth2\Server\Entities\ClientEntityInterface; | ||
use League\OAuth2\Server\EventEmitting\EmitterAwareInterface; | ||
use League\OAuth2\Server\EventEmitting\EmitterAwarePolyfill; | ||
use League\OAuth2\Server\Exception\OAuthServerException; | ||
use League\OAuth2\Server\Grant\GrantTypeInterface; | ||
use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface; | ||
use League\OAuth2\Server\Repositories\ClientRepositoryInterface; | ||
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; | ||
use Psr\Http\Message\ServerRequestInterface; | ||
|
||
use function base64_decode; | ||
use function explode; | ||
use function json_decode; | ||
use function substr; | ||
use function time; | ||
use function trim; | ||
|
||
abstract class AbstractHandler implements EmitterAwareInterface | ||
{ | ||
use EmitterAwarePolyfill; | ||
use CryptTrait; | ||
|
||
protected ClientRepositoryInterface $clientRepository; | ||
|
||
protected AccessTokenRepositoryInterface $accessTokenRepository; | ||
|
||
protected RefreshTokenRepositoryInterface $refreshTokenRepository; | ||
|
||
public function setClientRepository(ClientRepositoryInterface $clientRepository): void | ||
{ | ||
$this->clientRepository = $clientRepository; | ||
} | ||
|
||
public function setAccessTokenRepository(AccessTokenRepositoryInterface $accessTokenRepository): void | ||
{ | ||
$this->accessTokenRepository = $accessTokenRepository; | ||
} | ||
|
||
public function setRefreshTokenRepository(RefreshTokenRepositoryInterface $refreshTokenRepository): void | ||
{ | ||
$this->refreshTokenRepository = $refreshTokenRepository; | ||
} | ||
Comment on lines
+36
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. From |
||
|
||
/** | ||
* 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This line is the only change on this method, since |
||
) === false | ||
) { | ||
$this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); | ||
|
||
throw OAuthServerException::invalidClient($request); | ||
} | ||
} | ||
|
||
return $client; | ||
} | ||
Comment on lines
+51
to
+81
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved from |
||
|
||
/** | ||
* 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; | ||
} | ||
Comment on lines
+83
to
+105
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved from |
||
|
||
/** | ||
* 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 ?? '']; | ||
} | ||
Comment on lines
+107
to
+128
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved from |
||
|
||
/** | ||
* Parse request parameter. | ||
* | ||
* @param array<array-key, mixed> $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; | ||
} | ||
Comment on lines
+130
to
+158
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved from |
||
|
||
/** | ||
* 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); | ||
} | ||
Comment on lines
+160
to
+170
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved from |
||
|
||
/** | ||
* 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]; | ||
} | ||
Comment on lines
+172
to
+209
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved from |
||
|
||
/** | ||
* 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); | ||
} | ||
Comment on lines
+211
to
+221
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved from |
||
|
||
/** | ||
* 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); | ||
} | ||
Comment on lines
+223
to
+233
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved from |
||
|
||
/** | ||
* 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); | ||
} | ||
Comment on lines
+235
to
+245
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved from |
||
|
||
/** | ||
* Validate the given encrypted refresh token. | ||
* | ||
* @throws OAuthServerException | ||
* | ||
* @return array<non-empty-string, mixed> | ||
*/ | ||
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; | ||
} | ||
Comment on lines
+247
to
+281
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved from |
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -34,7 +34,7 @@ | |
use function preg_replace; | ||
use function trim; | ||
|
||
class BearerTokenValidator implements AuthorizationValidatorInterface | ||
class BearerTokenValidator implements AuthorizationValidatorInterface, JwtValidatorInterface | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Implements new |
||
{ | ||
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 | ||
{ | ||
Comment on lines
+102
to
+116
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Extract the JWT validation logic from |
||
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'); | ||
} | ||
|
||
Comment on lines
+138
to
+146
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new |
||
// 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(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace League\OAuth2\Server\AuthorizationValidators; | ||
|
||
use Psr\Http\Message\ServerRequestInterface; | ||
|
||
interface JwtValidatorInterface | ||
{ | ||
/** | ||
* Parse and validate the given JWT. | ||
* | ||
* @param non-empty-string $jwt | ||
* @param non-empty-string|null $clientId | ||
* | ||
* @return array<non-empty-string, mixed> | ||
*/ | ||
public function validateJwt(ServerRequestInterface $request, string $jwt, ?string $clientId = null): array; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Define new |
||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From
AbstractGrant
class.