Skip to content

JSON Web Token (JWT) auth option provider #1098

Description

@bramcordie

I've been working on custom Keycloak and Shibboleth (OIDC) providers that use signed JSON web tokens to authenticate the client instead of a secret. It is based on the specifications in this RFC: https://datatracker.ietf.org/doc/html/rfc7523#section-2.2.
Just like #653, I implemented a new auth option provider that allows you to bring your own JWT generator. Your provider probably already comes with a JWT library that you can easily plug in. This prevents any extra or unnecessary dependencies, and keeps the option provider itself very basic:

class JwtAuthOptionProvider extends PostAuthOptionProvider
{
    /**
     * @var callable():string
     */
    private $jwtGenerator;

    /**
     * @param callable():string $jwtGenerator
     */
    public function __construct(callable $jwtGenerator)
    {
        $this->jwtGenerator = $jwtGenerator;
    }

    #[\Override]
    public function getAccessTokenOptions($method, array $params)
    {
        $params['client_assertion'] = ($this->jwtGenerator)();
        $params['client_assertion_type'] = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';

        // The AbstractProvider adds client_id and client_secret to params if they are set.
        // When using a JWT, the client_secret should not be sent and the client id is already part of the JWT (iss, sub).
        unset($params['client_id'], $params['client_secret']);

        return parent::getAccessTokenOptions($method, $params);
    }
}

I'm using a custom Keycloak provider based on https://github.com/stevenmaguire/oauth2-keycloak, because it does not yet support JWTs for client authentication. I plan on upstreaming to the specific provider but I wanted to check here first for the auth option provider part. There might be other providers, that I'm not yet aware of, that could make us of it too.

I still have some testing to do with my custom providers but if there's any interest in the auth option provider part I would be happy to submit a pull request later on!

The existing Keycloak provider already depends on firebase/php-jwt so this is what the generator looks like using that same library:

use Firebase\JWT\JWT;

readonly class PrivateKeyJwtGenerator
{
    public const string ALG = 'RS256';
    public const string KID = 'rsa-sign';

    public function __construct(
        private string $clientId,
        private string $clientJwtKey,
        private string $tokenUrl,
    ) {
    }

    public function generate(): string
    {
        $timestamp = time();

        $payload = [
            'iss' => $this->clientId,
            'sub' => $this->clientId,
            'aud' => $this->tokenUrl,
            'jti' => bin2hex(random_bytes(16)),
            'iat' => $timestamp,
            'nbf' => $timestamp - 60,
            'exp' => $timestamp + 60,
        ];

        return JWT::encode($payload, $this->clientJwtKey, self::ALG, self::KID);
    }

    public function __invoke(): string
    {
        return $this->generate();
    }
}
class CustomProvider extends AbstractProvider
{
    /**
     * The RS256 (openssl) private key used to sign JWTs.
     */
    public ?string $clientJwtKey = null;

    public function __construct(array $options = [], array $collaborators = [])
    {
        parent::__construct($options, $collaborators);

        if (isset($this->clientJwtKey)) {
            $jwtGenerator = new PrivateKeyJwtGenerator(
                $this->clientId,
                $this->clientJwtKey,
                $this->getBaseAccessTokenUrl([]),
            );

            $this->setOptionProvider(new JwtAuthOptionProvider($jwtGenerator));
        }
    }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions