Search by

christianjbrown / oauth2-client

christianjbrown

A thin, strongly-typed PHP 8.5+ OAuth 2.0 client that manages access tokens (refresh-token and client-credentials grants), caching them behind a mockable key-value store.

Package info

github.com/christianjbrown/oauth2-client-php

pkg:composer/christianjbrown/oauth2-client

Statistics

Installs: 226

Dependents: 3

Suggesters: 0

Stars: 0

Open Issues: 0

v2.1.1 2026-10-01 11:29 UTC

README

CI Coverage Packagist License PHP

A small, strongly-typed PHP OAuth 2.0 client that fetches and caches access tokens. It hides the token endpoint behind a couple of token managers, caches the resulting access (and refresh) token in an interchangeable key-value store, and only calls the endpoint again when the cached token is missing, expired, or a refresh is forced.

Two grant types ship today:

  • Refresh token (RefreshTokenManager) — exchanges a stored refresh token for a new access token.
  • Client credentials (ClientCredentialsTokenManager) — exchanges HTTP Basic credentials for an access token.

Both return an AccessTokenInterface and normalise transport and payload failures into a single library exception hierarchy, so callers stay decoupled from the underlying HTTP client.

✔️ Prerequisites

💡 If you're on MacOS and have Homebrew, PHP and Composer will install with brew install composer.

🏗️ Installation

For your composer-enabled project:

composer require christianjbrown/oauth2-client

💻 Usage

Build a manager with its factory. Each factory takes a PSR-20 clock (these examples use Symfony\Component\Clock\NativeClock) and builds the manager from a JSON API request sender (from api-client), one or more key-value stores for the cached tokens, the token endpoint URL and a lock. Pass NullLock when only one process refreshes tokens at a time.

🔄 Refresh token grant

use ChristianBrown\OAuth2Client\Authentication\PublicClientAuthentication;
use ChristianBrown\OAuth2Client\Lock\NullLock;
use ChristianBrown\OAuth2Client\RefreshTokenManagerFactory;
use Symfony\Component\Clock\NativeClock;

$manager = (new RefreshTokenManagerFactory(new NativeClock()))->create(
    $jsonApiRequestSender,           // ChristianBrown\ApiClient\JsonApiRequestSenderInterface
    $accessTokenStore,               // ChristianBrown\KeyValueStore\TtlAwareKeyValueStoreInterface
    $refreshTokenStore,              // ChristianBrown\KeyValueStore\KeyValueStoreInterface
    'https://example.com/oauth/token',
    new PublicClientAuthentication(), // or new ClientSecretBasicAuthentication('my-client-secret')
    new NullLock(),                  // or your own LockInterface implementation
);

$accessToken = $manager->getAccessToken('my-client-id');

$accessToken->getAccessToken(); // the bearer token string
$accessToken->getExpiresIn();   // seconds until expiry

// Force a refresh even if a valid token is cached:
$accessToken = $manager->getAccessToken('my-client-id', true);

The manager returns the cached access token while it is still valid. Otherwise it POSTs the stored refresh token to the endpoint, caches the new access and refresh tokens, and returns the fresh token.

🔑 Client credentials grant

use ChristianBrown\OAuth2Client\ClientCredentialsTokenManagerFactory;
use ChristianBrown\OAuth2Client\Lock\NullLock;
use Symfony\Component\Clock\NativeClock;

$manager = (new ClientCredentialsTokenManagerFactory(new NativeClock()))->create(
    $jsonApiRequestSender, // ChristianBrown\ApiClient\JsonApiRequestSenderInterface
    $accessTokenStore,     // ChristianBrown\KeyValueStore\TtlAwareKeyValueStoreInterface
    'https://example.com/oauth/token',
    new NullLock(),
);

// The Basic auth value is the raw "client_id:client_secret"; the manager base64-encodes it.
$accessToken = $manager->getAccessTokenFromBasicAuth(
    'my-client-id:my-client-secret',
    'my-scope',   // optional
    'my-client-id', // optional
);

$accessToken->getAccessToken();

⬆️ Upgrading to 2.0

The manager constructors now take their collaborators, so build managers with the factories. AccessTokenTransformer and LockInterface are unchanged.

// Before
$manager = new RefreshTokenManager($sender, $accessStore, $refreshStore, new AccessTokenTransformer(), $url, $clientSecret, $lock);
$manager = new ClientCredentialsTokenManager($sender, $accessStore, new AccessTokenTransformer(), $url);

// After
$factory = new RefreshTokenManagerFactory(new NativeClock());
$manager = $factory->create($sender, $accessStore, $refreshStore, $url, new ClientSecretBasicAuthentication($clientSecret), $lock);
$manager = (new ClientCredentialsTokenManagerFactory(new NativeClock()))->create($sender, $accessStore, $url, new NullLock());

Without a client secret use new PublicClientAuthentication(); without a lock use new NullLock().

🎫 The access token

Every manager returns a ChristianBrown\OAuth2Client\Model\AccessTokenInterface:

public function getAccessToken(): string;
public function getExpiresIn(): int;
public function getRefreshToken(): ?string;
public function getScope(): ?string;
public function getTokenType(): AccessTokenType; // enum, currently AccessTokenType::BEARER

🚨 Error handling

Everything the library throws implements ChristianBrown\OAuth2Client\Model\Exception\ExceptionInterface (which extends Throwable):

  • RequestExceptionInterface — the token endpoint request failed. The original api-client exception is available via getRequestException().
  • BadResponsePayloadFieldExceptionInterface — the endpoint responded, but a field was missing, the wrong type, or an unsupported value. getField() and getData() expose the offending field and the full payload.
use ChristianBrown\OAuth2Client\Model\Exception\ExceptionInterface;

try {
    $accessToken = $manager->getAccessToken('my-client-id');
} catch (ExceptionInterface $e) {
    print $e->getMessage();
}

📝 Changelog

Notable changes in each release are listed in CHANGELOG.md.

📄 License

Released under the MIT License.