Replace JWT Micropub tokens with revocable opaque tokens

Tokens now store a hashed row in micropub_tokens instead of being
self-contained signed JWTs, so a leaked or unwanted token can actually
be revoked. Since revocation already requires a DB lookup on every
request, JWT's stateless-verification benefit was gone anyway, so this
also drops the lcobucci/jwt dependency entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L
This commit is contained in:
Jonny Barnes 2026-08-13 16:07:07 +01:00
commit d5706b5f8f
Signed by: jonny
SSH key fingerprint: SHA256:CTuSlns5U7qlD9jqHvtnVmfYV3Zwl2Z7WnJ4/dqOaL8
10 changed files with 124 additions and 181 deletions

View file

@ -4,18 +4,16 @@ declare(strict_types=1);
namespace Tests\Feature;
use App\Models\MicropubToken;
use App\Services\TokenService;
use DateTimeImmutable;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Key\InMemory;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class TokenServiceTest extends TestCase
{
/**
* Given the token is dependent on a random nonce, the time of creation and
* the APP_KEY, to test, we shall create a token, and then verify it.
* Given the token is dependent on a random value and stored only as a
* hash, to test, we shall create a token, and then verify it.
*/
#[Test]
public function tokenservice_creates_valid_tokens(): void
@ -41,24 +39,29 @@ class TokenServiceTest extends TestCase
}
#[Test]
public function tokens_with_different_signing_key_are_not_valid(): void
public function unknown_tokens_are_not_valid(): void
{
$response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer '.bin2hex(random_bytes(32))]);
$response->assertJson([
'response' => 'error',
'error' => 'invalid_token',
'error_description' => 'The provided token did not pass validation',
]);
}
#[Test]
public function revoked_tokens_are_not_valid(): void
{
$tokenService = new TokenService;
$data = [
'me' => 'https://example.org',
'client_id' => 'https://quill.p3k.io',
'scope' => 'post',
];
$token = $tokenService->getNewToken($data);
$config = resolve(Configuration::class);
$token = $config->builder()
->issuedAt(new DateTimeImmutable)
->withClaim('client_id', $data['client_id'])
->withClaim('me', $data['me'])
->withClaim('scope', $data['scope'])
->withClaim('nonce', bin2hex(random_bytes(8)))
->getToken($config->signer(), InMemory::plainText(random_bytes(32)))
->toString();
MicropubToken::where('token_hash', hash('sha256', $token))->firstOrFail()->revoke();
$response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer '.$token]);

View file

@ -2,53 +2,39 @@
namespace Tests;
use DateTimeImmutable;
use Lcobucci\JWT\Configuration;
use App\Services\TokenService;
trait TestToken
{
public function getToken(): string
{
$config = $this->app->make(Configuration::class);
return $config->builder()
->issuedAt(new DateTimeImmutable)
->withClaim('client_id', 'https://quill.p3k.io')
->withClaim('me', 'http://jonnybarnes.localhost')
->withClaim('scope', ['create', 'update'])
->getToken($config->signer(), $config->signingKey())
->toString();
return $this->app->make(TokenService::class)->getNewToken([
'client_id' => 'https://quill.p3k.io',
'me' => 'http://jonnybarnes.localhost',
'scope' => 'create update',
]);
}
public function getTokenWithIncorrectScope(): string
{
$config = $this->app->make(Configuration::class);
return $config->builder()
->issuedAt(new DateTimeImmutable)
->withClaim('client_id', 'https://quill.p3k.io')
->withClaim('me', 'https://jonnybarnes.localhost')
->withClaim('scope', 'view')
->getToken($config->signer(), $config->signingKey())
->toString();
return $this->app->make(TokenService::class)->getNewToken([
'client_id' => 'https://quill.p3k.io',
'me' => 'https://jonnybarnes.localhost',
'scope' => 'view',
]);
}
public function getTokenWithNoScope()
public function getTokenWithNoScope(): string
{
$config = $this->app->make(Configuration::class);
return $config->builder()
->issuedAt(new DateTimeImmutable)
->withClaim('client_id', 'https://quill.p3k.io')
->withClaim('me', 'https://jonnybarnes.localhost')
->getToken($config->signer(), $config->signingKey())
->toString();
return $this->app->make(TokenService::class)->getNewToken([
'client_id' => 'https://quill.p3k.io',
'me' => 'https://jonnybarnes.localhost',
'scope' => '',
]);
}
public function getInvalidToken()
public function getInvalidToken(): string
{
$token = $this->getToken();
return substr($token, 0, -5);
return bin2hex(random_bytes(32));
}
}