Download the PHP package idct/single-use-token-manager without Composer
On this page you can find all versions of the php package idct/single-use-token-manager. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.
Download idct/single-use-token-manager
More information about idct/single-use-token-manager
Files in idct/single-use-token-manager
Package single-use-token-manager
Short Description Single-use token manager built on the PSR-16 simple cache contract, with optional tag based invalidation.
License BSD-3-Clause
Informations about the package single-use-token-manager
Single Use Token Manager
Hand a user something they can redeem exactly once: a password reset link, an email confirmation, a one-off download, a payment callback that must not run twice. The library issues a token into a cache and takes it back out again, and the cache handles expiry, so there is no sweeping job to write and nothing to migrate.
Storage is any PSR-16 cache. When the cache also supports tagging, clearing tokens reaches the tokens and nothing else.
Installation
You also need a PSR-16 cache. Two that work well:
Getting started
example.php in the repository walks through the same ground in more detail and
runs as it is:
How it works
A token is stored as one cache entry under the key TKN_ plus its identifier.
The identifier is a UUID v4, built from random_bytes(). That matters more than
it looks: a token is a bearer capability, so whoever holds the identifier can
perform the action, and only a cryptographically secure source makes it
unguessable. Time ordered versions are deliberately avoided, for reasons set out
in Why the identifier is random. Expiry is the cache's job: a
lifetime handed to createToken() goes straight through to the PSR-16 write, so
an expired token simply is not there any more.
consumeToken() reads the entry and removes it in the same call. It returns
null for a token that was never issued, one that has expired, one already
redeemed, and one whose cache entry turned out to hold something else, so
calling code only needs the single null check. How that behaves when several
callers arrive together is covered in Concurrency.
The API
TokenService
| Method | What it does |
|---|---|
createToken(string $type, mixed $payload = null, ?int $ttl = null, ?string $uid = null): TokenInterface |
Issues a token and stores it. Pass $uid only to make the token addressable; otherwise it gets an unguessable one. Throws InvalidArgumentException for an unusable type or identifier, and TokenStorageException when the cache refuses the write. |
consumeToken(string $uid, bool $keepToken = false): ?TokenInterface |
Redeems a token, removing it unless $keepToken says otherwise. Returns null when there is no live token under that identifier. |
clearAllTokens(): bool |
Drops every token. See Clearing tokens for what that costs on a cache without tagging. |
The token type
A type is lowercase letters and digits, one to sixteen characters. That keeps it
safe to put in a cache key or a URL without escaping. reset, verify2fa and
invite are fine; Password Reset, pass_reset and an empty string are
refused with an InvalidArgumentException before anything is written.
The payload
The payload is whatever the code redeeming the token will need. It travels through the cache, so it has to survive that cache's serialisation. Arrays and plain objects are safe; open resources and closures are not.
Checking a token without spending it
Pass keepToken: true to look a token up without redeeming it. This is for
read-only checks, such as rendering a page that says the link is still valid:
Do not use it as a gate in front of a second, redeeming call:
The gap between the check and the spend is exactly where a second request gets in, and the work in the middle widens it. Spend the token first, then do the work:
Reporting a refused write
PSR-16 write methods report failure by returning false rather than by throwing.
A service that ignored that would hand back a token the cache never stored, and
the user would be sent something they could never redeem. createToken() turns
the false into a TokenStorageException instead.
consumeToken() treats a refused delete the same way, raising a
TokenRemovalException. A token the cache would not remove is still redeemable,
so reporting the failure is better than telling the caller it was spent. The
redeemed token is not returned in that case: losing one legitimate redemption
beats leaving a token open to replay.
Why the identifier is random
A single-use token is a bearer capability: possession of the identifier is the whole authorisation. That puts it in the same category as a session cookie, not the same category as a database primary key, and it has to be unguessable.
Time ordered UUIDs are not. A v6 identifier carries a node that Symfony computes once per process and then repeats on every token that process issues, so tokens from one php-fpm worker all share their last twelve characters:
One leaked token gives that away for all the others, and the rest is mostly a clock. An attacker who can request a token for their own account learns the node and pins the time, leaving far too little to guess. A v7 identifier is no better here: Symfony seeds it once and then increments, so consecutive values sit next to each other.
RFC 9562 makes the same point in its security considerations: do not assume a UUID is hard to guess, and do not use one as a security capability unless it comes from a CSPRNG.
So the identifier is a v4, which Symfony builds from random_bytes(). The cost
is the loss of time ordering, which only ever bought cache index locality, and
that is worth very little for entries fetched by exact key that expire on their
own.
Addressable tokens
Sometimes the request that redeems a token has nowhere to carry a random identifier. A mobile client posting back an account id and a six digit code the user copied out of an e-mail is the usual shape: the fields are fixed, none of them is 36 characters wide, and the client cannot be changed. The token still has to be findable.
Pass $uid to createToken() and the token is stored under it:
This is a deliberate trade, and it moves two duties onto you.
The identifier is no longer the secret. Deriving it from a user id — or an order number, or anything else an attacker can enumerate — means reaching the token proves nothing at all. Whatever actually authorises the action has to travel in the payload and be checked after the token comes back:
Compare that with the default: a random identifier is itself proof, so a
non-null return is the whole check. Give the payload check the same care you
would give a password comparison — hash_equals, and a rate limit on top,
because a short code taken from an e-mail carries far less entropy than a v4.
Uniqueness becomes yours. Two tokens built with the same identifier are one cache entry, and the second silently replaces the first. That is often what you want, since it gives a flow one live token per user and lets a resend overwrite what came before — but it is worth being deliberate about rather than surprised by.
Identifiers are checked for the characters PSR-16 reserves ({}()/\@:) and for
being empty, and rejected with InvalidArgumentException before anything
reaches the cache. Everything else is yours to choose. The service namespace, if
you set one, still applies, so two services sharing a pool cannot collide even
on identical identifiers.
Concurrency
Whether a token really is single use depends on what the cache can do.
PSR-16 has no way to take a value. Reading is get() and removing is
delete(), two calls with a gap between them, and every caller that arrives
inside that gap reads a token that is still there. Eight processes redeeming one
token simultaneously against a plain PSR-16 cache all succeed.
When the cache can read and remove in one operation, consumeToken() uses that
instead and exactly one caller wins. Declare the capability by implementing
IDCT\SingleUseTokenManager\Contract\AtomicCacheInterface:
The service detects the method rather than the interface, so any cache carrying
a compatible take() is used as-is. Backing it is usually one command: Redis
and Valkey have had GETDEL since 6.2, which phpredis exposes as getDel().
idct/php-rapid-cache-client ships take() from 1.1 onwards, so it gives
you both halves at once: tag-scoped clearing and single use that holds under
concurrency, with no wiring beyond constructing it.
tests/Concurrency/single-use-under-load.php demonstrates both halves. It lines
eight processes up on one token and asserts that an atomic cache yields exactly
one winner while a plain one does not, so neither this section nor the guarantee
can drift without the build noticing:
If more than one request can present the same token at once, and both succeeding would matter, use a cache that can take atomically.
Clearing tokens
clearAllTokens() behaves differently depending on what the cache can do.
On a plain PSR-16 cache the only bulk operation available is clear(),
which empties the entire pool. Anything else sharing that pool goes with the
tokens. Give the token service its own cache pool if that matters.
On a cache that supports tagging every token is written under the tag TKN
and clearing invalidates just that tag. Nothing else in the pool is touched.
The service works this out on its own, by checking whether the cache exposes
setTagged() and clearByTag(). No configuration and no wiring:
To declare the capability on a cache of your own, implement
IDCT\SingleUseTokenManager\Contract\TaggedCacheInterface. Its two methods have
the same signatures as the ones on IDCT\Cache\CacheServiceInterface, so a
cache satisfying one satisfies the other, and this package keeps
idct/php-rapid-cache-client an optional dependency rather than a required one.
Sharing a cache between services
By default every token service writes keys as TKN_<uid> and tags them TKN.
Two services sharing one pool would therefore share a key space, and clearing
one would clear the other. Pass a namespace to keep them apart:
The namespace prefixes both the key and the tag. Leaving it out keeps the keys byte for byte as earlier versions wrote them, so this is safe to adopt on an existing deployment.
PSR-16 reserves the characters {}()/\@:, and a namespace containing any of
them is refused at construction rather than left to fail later as an unreadable
cache error.
Note that on a cache without tagging clearAllTokens() still empties the whole
pool, because clear() is all PSR-16 offers. The namespace separates the two
services' keys, not their blast radius. See Clearing tokens.
Validating an identifier from a request
TokenIdentifier is the request object an endpoint hydrates before redeeming. It
carries Symfony Validator, Serializer and OpenAPI attributes, so the identifier
is validated, named token in JSON, and documented in the API schema without
the endpoint hand rolling any of it.
An empty identifier is rejected, and so is one made only of whitespace, which would otherwise slip through as a cache miss further down the line.
The OpenAPI attribute is inert unless you install zircote/swagger-php, which
is why this package does not require it. PHP does not load an attribute class
until something reflects over it, so the attribute costs nothing when the
generator is absent. Install it when you want the schema:
Layout
Requirements
- PHP 8.2 or newer, tested on 8.2, 8.3, 8.4 and 8.5
- Symfony Serializer, Uid and Validator, 7.4 or newer, or 8.0 or newer. Symfony 8 itself needs PHP 8.4, so PHP 8.2 and 8.3 resolve to the 7.4 line
- A PSR-16 cache
- Docker and Docker Compose, for the functional tests that need a server
Testing
Every one of these runs on every CI job, across PHP 8.2 to 8.5 and against both the newest and the lowest allowed dependency resolution. Nothing is gated to a single job, so a failure that only shows up under one version or one resolution still fails the build.
The suite holds four lines and the build fails below any of them: 100% line
coverage, 100% method coverage, a mutation score index of 100%, and PHPStan at
level max over src, tests and example.php. There is no PHPStan baseline
and no ignored error, so a new finding fails the build instead of being
recorded and forgotten. Coverage is published to
Codecov from each
job, flagged by PHP version and dependency mode.
What the functional tests cover
The scenarios in features/service run unchanged against three different
caches, because the service is meant to need nothing beyond PSR-16:
| Suite | Cache | Tagging | Needs a server |
|---|---|---|---|
model |
none | not applicable | no |
array_cache |
Psr16Cache over ArrayAdapter |
no | no |
redis_no_tags |
Psr16Cache over RedisAdapter |
no | Redis on 6379 |
rapid_cache_tags |
RapidCacheClient |
yes | Valkey on 6380 |
Run one at a time with ./vendor/bin/behat --suite=rapid_cache_tags. The
containers behind the last two come from docker-compose.yml:
Changelog
CHANGELOG.md records what changed in each release. If you are
coming from 1.x under the old GryfOSS namespace, it also carries the upgrade
steps.
Licence
BSD 3-Clause. See LICENSE.
All versions of single-use-token-manager with dependencies
psr/simple-cache Version ^3.0
symfony/serializer Version ^7.4 || ^8.0
symfony/uid Version ^7.4 || ^8.0
symfony/validator Version ^7.4 || ^8.0