vaclavvanik / oauth2-token
OAuth 2.0 access-token contract with pluggable, expiry-aware token caching
Requires
- php: ^7.3 || ^8.0
- ext-json: *
- psr/http-client: ^1.0
- psr/http-message: ^1.0 || ^2.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-09 10:13:43 UTC
README
A small contract for obtaining an OAuth 2.0 access token, plus a caching decorator that hands back the token it already has until it is about to expire - so an API client is not doing a token round-trip before every request.
Why
Plenty of APIs hand out a short-lived bearer token in exchange for a client id and secret (the
client_credentials grant, or something close to it). The token fetch is trivial; the annoying part is
not doing it every time. This package is that annoying part, done once:
TokenProvider- a one-method contract,getToken(TokenRequest $request): AccessToken, that you implement once per API (or pull in a ready-made implementation).CachingTokenProvider- a decorator around anyTokenProviderthat stores the token and reuses it until it is within a refresh delta of expiry, then fetches a new one. It is aRefreshableTokenProvider, so it also lets youforget()a token that was revoked before its expiry.ExpirationValidator- a JWT access token expires on its ownexpclaim rather than a guessed lifetime; a JWT withoutexp, and every non-JWT token, falls back to theexpires_infrom the token response.- Pluggable storage: in-memory for a single process, a JSON file for sharing across processes, or your own
Cache\Repository.
No HTTP client is pulled in - the transport lives in the concrete TokenProvider you provide; the only
runtime dependencies are the psr/http-client and psr/http-message interface packages. Tested on
PHP 7.3 - 8.5.
Install
composer require vaclavvanik/oauth2-token
Usage
Implement the contract
<?php declare(strict_types=1); use VaclavVanik\Oauth2Token\AccessToken; use VaclavVanik\Oauth2Token\TokenProvider; use VaclavVanik\Oauth2Token\TokenRequest; final class AcmeTokenProvider implements TokenProvider { // ... your PSR-18 / Guzzle / curl call ... public function getToken(TokenRequest $request): AccessToken { $body = $this->post('https://acme.example/oauth/token', [ 'grant_type' => 'client_credentials', 'client_id' => $request->getClientId(), 'client_secret' => $request->getClientSecret(), 'scope' => $request->getScope(), // "read write" or null ]); return AccessToken::fromValidatedArray($body); } }
fromValidatedArray() needs the token lifetime to be knowable - either an expires_in in the response, or a
JWT access_token with an exp claim - otherwise it throws. If your provider returns an opaque token with a
lifetime you only know from its docs, supply it yourself:
return AccessToken::fromArray($body + [AccessToken::EXPIRES_IN => 3600]);
Wrap it with caching
<?php declare(strict_types=1); use VaclavVanik\Oauth2Token\CachingTokenProviderFactory; use VaclavVanik\Oauth2Token\RefreshableTokenProvider; // Both factory methods return a RefreshableTokenProvider (getToken() + forget()). // Shared across processes - the token survives until it expires. $provider = CachingTokenProviderFactory::createJsonFile(new AcmeTokenProvider(), '/var/cache/acme-token.json'); // Or, for a single long-running process: $provider = CachingTokenProviderFactory::createMemory(new AcmeTokenProvider());
Use the token
<?php declare(strict_types=1); use VaclavVanik\Oauth2Token\TokenRequest; $token = $provider->getToken(new TokenRequest($clientId, $clientSecret)); // or (..., ['read', 'write']) $httpRequest = $httpRequest->withHeader('Authorization', $token->headerValue()); // "Bearer eyJhbGci..."
getToken() returns a cached token when one is still valid and only calls the wrapped TokenProvider when
the cache misses or the stored token is within the refresh delta of expiry. Tokens are cached per client id +
secret + scope set.
There is no lock around the fetch: if several processes hit a cold or expired cache at the same moment, each
fetches its own token. Fine for most providers; if yours rate-limits the token endpoint hard, pre-warm the
cache or add locking in a custom Repository.
Forcing a refresh
The cache cannot see a token that was revoked before its expiry - the API just starts rejecting it. RFC 6750
says that is a 401 with error="invalid_token", but check what your API actually does (some use 403).
Catch it, forget() the token, and retry once with a fresh one:
<?php declare(strict_types=1); use VaclavVanik\Oauth2Token\TokenRequest; $tokenRequest = new TokenRequest($clientId, $clientSecret); $token = $provider->getToken($tokenRequest); $response = $api->send($httpRequest->withHeader('Authorization', $token->headerValue())); if ($response->getStatusCode() === 401) { // adjust to your API $provider->forget($tokenRequest); $token = $provider->getToken($tokenRequest); $response = $api->send($httpRequest->withHeader('Authorization', $token->headerValue())); }
forget() is part of RefreshableTokenProvider (what the factory returns), not the base TokenProvider
interface - type-hint RefreshableTokenProvider where you need it.
Refresh delta
By default a token is treated as expired 60 seconds before its real expiry, leaving room for clock skew and the request itself. Override it when building the decorator:
<?php declare(strict_types=1); use VaclavVanik\Oauth2Token\CachingTokenProviderFactory; $provider = CachingTokenProviderFactory::createJsonFile( new AcmeTokenProvider(), '/var/cache/acme-token.json', null, // clock - defaults to "now" 120, // refresh delta, in seconds );
Storage
| Repository | Use it for |
|---|---|
Cache\MemoryRepository |
a single long-running process; nothing persists |
Cache\JsonFileRepository |
sharing a token across CLI runs / workers; writes atomically, drops expired entries on save, and treats a corrupt file as an empty cache the next save rebuilds (a file it cannot read at all - permissions, a directory - still throws) |
your own Cache\Repository |
Redis, APCu, PSR-6/PSR-16, ... - four methods: has, load, save, delete |
Cache entries are keyed by Cache\Key - an md5 of the client id, secret and the
TokenRequest scope string. Scopes are a set, so ['read', 'write'] and ['write', 'read'] share one
entry. The secret is used only to derive the hash: this package never exposes it through its API and never
puts it in an exception or a log line.
Exceptions
getToken() fails in one of two ways, and callers are meant to tell them apart:
Psr\Http\Client\NetworkExceptionInterface- the token endpoint could not be reached (DNS, connection refused, TLS, timeout). Transient; a retry may succeed. TheTokenProvidercontract lets this bubble up from the implementation untouched, socatch (NetworkExceptionInterface $e)aroundgetToken()works.Exception\Runtime- the exchange itself failed (a malformed response body, a token whose lifetime cannot be determined, a failed cache write, a clock error). Not retryable without a change.
Everything else this package throws implements Exception\Exception:
Exception\ErrorResponse- the token endpoint answered with an OAuth error; carriesgetError(),getErrorDescription(),getErrorUri()and the PSR-7getResponse(). Thrownew ErrorResponse($response, $error, $description, $uri)(only$erroris required) from yourTokenProviderimplementation on a non-2xx response.Exception\Runtime- as above. Corrupt cache content is not an error - it degrades to an empty cache that rebuilds itself - but a cache file that cannot be read or written at all (permissions, a directory) throws this, carrying the underlying reason.Cache\Exception\NotFound- a repository was asked toload()a key it does not hold.
Run check - coding standards and php-unit
Install dependencies:
make install
Run check:
make check
Changelog
Please see CHANGELOG for more information what has changed recently.
License
The MIT License (MIT). Please see License File for more information.