packagist gesdinet/jwt-refresh-token-bundle v2.3.0

4 hours ago

Upgrade if you have hash_tokens enabled. The value stored for a refresh token was accepted as the token itself, so being able to read the table was enough to refresh with any row in it. This is the 2.x line's fix for it; 3.1.0 carries the same one.

A minor rather than 2.2.3 because it also adds public API. Nothing is deprecated, nothing breaks, and there is no schema change.

Security: the stored hash was accepted as the refresh token

HashedRefreshTokenManager::get() hashes what is presented and looks that up. A value that is already a stored hash cannot match there, because that lookup asks for the hash of a hash, so it fell through to the fallback that keeps tokens issued before hashing was turned on working. That fallback looks the presented value up as it is, and the stored value matches the row holding it. save() then saw the sha256$ prefix and left the row alone, so the same value went on working indefinitely, and with single_use each rotated row was as usable as the one before it.

Reading the table is the one thing hashing exists to survive, so this took the point out of the option entirely. A value in the stored format is now refused before the fallback runs, and not looked up at all: the sha256$ prefix is reserved, and no client is ever given a value carrying it.

Affects 2.2.0, 2.2.1, 2.2.2 and 3.0.0 with hash_tokens enabled. It is not reachable without read access to the stored rows.

If you cannot upgrade right away, this closes the only way in, on every affected version:

gesdinet_jwt_refresh_token:
    hash_tokens:
        enabled: true
        accept_stored_in_the_clear: false

That also stops accepting tokens issued before hashing was turned on, so any session still holding one is signed out.

Reported privately by Rathnadeepan (HotWater), who also proposed the fix taken here. A GitHub security advisory is being filed.

Clearing expired tokens without reading them first

revokeAllInvalid() and revokeAllInvalidBatch() hydrate every token they delete, because they hand them back and the object manager wants the objects to remove them. That is what lets gesdinet:jwt:clear list what it revoked and what raises the life-cycle events an application may be listening for, and it costs one object per dead row. Clearing a table that has grown for months is the one thing it is bad at.

Model\ExpiredRefreshTokenManagerInterface::revokeAllExpired() sends a single statement instead, so what it costs no longer grows with the backlog:

use Gesdinet\JWTRefreshTokenBundle\Model\ExpiredRefreshTokenManagerInterface;

public function __construct(
    private ExpiredRefreshTokenManagerInterface $refreshTokenManager,
) {
}

public function clearOut(): int
{
    // Everything that had expired by now. Pass a date to clear what had expired by then instead
    return $this->refreshTokenManager->revokeAllExpired();
}

The trade is that the tokens are only counted: nothing is hydrated, so no life-cycle events are raised and what went cannot be reported. revokeAllInvalid() and revokeAllInvalidBatch() hand the tokens back and are what an application listening for their removal wants instead.

gesdinet:jwt:clear is unchanged on this branch. The --direct flag that runs this from the command line is in 3.1.0 only, so nothing a maintenance release does changes behind an existing invocation.

Available with the ORM, ODM and DBAL backends. A repository of your own gains it by implementing Doctrine\DeleteExpiredRefreshTokenRepositoryInterface, and the manager says which interface is missing rather than calling a method that is not there.

The implementation is @Rainrider's, from #432, which he withdrew.

Don't miss a new jwt-refresh-token-bundle release

NewReleases is sending notifications on new releases.