Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
fbc0289
support token revocation and introspection
hafezdivandari Feb 14, 2025
704a3cf
formatting
hafezdivandari Feb 14, 2025
9555b91
Merge branch 'master' into master-token-revocation-introspection
hafezdivandari Feb 15, 2025
3482d6f
formatting
hafezdivandari Feb 16, 2025
68eded0
formatting
hafezdivandari Feb 18, 2025
f52f03d
update readme
hafezdivandari Feb 18, 2025
09d284b
add tests
hafezdivandari Feb 18, 2025
70bf835
formatting
hafezdivandari Feb 18, 2025
3c574f2
formatting
hafezdivandari Feb 19, 2025
bed1164
Merge branch 'master' into master-token-revocation-introspection
hafezdivandari Nov 6, 2025
a3f0080
add SensitiveParameter attribute
hafezdivandari Nov 6, 2025
4a91753
use explicit comparison
hafezdivandari Nov 28, 2025
f1d69be
remove pragma header
hafezdivandari Nov 28, 2025
b11ee9b
separate functions to parse tokens by type
hafezdivandari Nov 28, 2025
8fe3382
rename `convertTimestamp` method to `getTimestamp`
hafezdivandari Nov 28, 2025
c10eb55
change visibility to private wherever possible
hafezdivandari Nov 28, 2025
d0375df
add a comment
hafezdivandari Nov 28, 2025
c7702a9
rename `JwtValidatorInterface` interface to `BearerTokenValidatorInte…
hafezdivandari Nov 28, 2025
f108e2f
rename `setToken` method to `setTokenData``
hafezdivandari Nov 28, 2025
3634409
invert `if` statement
hafezdivandari Nov 28, 2025
e45c21b
formatting
hafezdivandari Nov 28, 2025
5d2e571
fix tests
hafezdivandari Nov 28, 2025
82a49d6
formatting
hafezdivandari Nov 28, 2025
b2f8661
return associative array when validating token
hafezdivandari Nov 29, 2025
2915076
formatting
hafezdivandari Nov 29, 2025
dcd57ed
Merge branch 'master' into master-token-revocation-introspection
hafezdivandari Nov 29, 2025
803978c
add examples
hafezdivandari Nov 29, 2025
d84b6df
remove redundant test
hafezdivandari Nov 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
282 changes: 282 additions & 0 deletions src/AbstractHandler.php
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been racking my brains trying to think of a better name for this handler. I understand the need to have a common base for the TokenHandler and the AbstractGrant, but the name AbstractHandler feels too vague to me and I worry it will cause issues later down the line.

Having a look at the methods in this class, nearly all of them are related to the request e.g. validating the client, extracting params in various scenarios etc but the one that doesn't quite fit this is the validation of the encrypted refresh token.

Can you think of any name that is more appropriate? Its clear what should go in the Abstract Grant and Abstract Token but I don't think it will be clear at first glance what a Handler is

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you noted, this class is extended by both AbstractGrant and AbstractTokenHandler. Since those classes expose public methods that respond to different incoming requests, they are effectively responsible for handling those requests. That is why I chose the Handler prefix for this base class AbstractHandler.

{
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;
}

/**
* 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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old validateClient simply returned a bool. I think this is slightly cleaner as this function is validating and retrieving the client entity. I think it is better when functions have one specific purpose. Is there a reason for this change and is it feasible to change back?

Copy link
Contributor Author

@hafezdivandari hafezdivandari Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There’s actually no change in the return value of this method, it wasn’t returning a boolean before either. You may check this:

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
*/
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<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;
}

/**
* 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<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;
}
}
33 changes: 26 additions & 7 deletions src/AuthorizationValidators/BearerTokenValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
use function preg_replace;
use function trim;

class BearerTokenValidator implements AuthorizationValidatorInterface
class BearerTokenValidator implements AuthorizationValidatorInterface, JwtValidatorInterface
{
use CryptTrait;

Expand Down Expand Up @@ -100,6 +100,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);
Expand All @@ -121,16 +136,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();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we just pass the claims back here instead of claims all? We could then use the get method as we did previously

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$claims is an instance of \Lcobucci\JWT\Token\DataSet, and I don’t want this interface’s return type to depend on that package.

}
}
20 changes: 20 additions & 0 deletions src/AuthorizationValidators/JwtValidatorInterface.php
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;
}
14 changes: 14 additions & 0 deletions src/Exception/OAuthServerException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to handle the 503 response where the client may try again later? I don't think we are covering this scenario at the moment

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the moment, tokens are revoked immediately, so we never return a 503 response in this implementation. Given that, this scenario does not currently occur and handling retries for 503 does not seem necessary right now.

$hint
);
}

/**
* Generate a HTTP response.
*/
Expand Down
Loading