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

@ -15,7 +15,6 @@ use App\Services\Micropub\MicropubHandlerRegistry;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Lcobucci\JWT\Token;
class MicropubController extends Controller
{
@ -135,7 +134,7 @@ class MicropubController extends Controller
}
// the default response is just to return the token data
/** @var Token $tokenData */
/** @var array $tokenData */
$tokenData = $request->input('token_data');
return response()->json([

View file

@ -5,13 +5,9 @@ declare(strict_types=1);
namespace App\Http\Middleware;
use App\Http\Responses\MicropubResponses;
use App\Models\MicropubToken;
use Closure;
use Illuminate\Http\Request;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Encoding\CannotDecodeContent;
use Lcobucci\JWT\Token;
use Lcobucci\JWT\Token\InvalidTokenStructure;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use Symfony\Component\HttpFoundation\Response;
class VerifyMicropubToken
@ -39,15 +35,17 @@ class VerifyMicropubToken
], 401);
}
try {
$tokenData = $this->validateToken($rawToken);
} catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) {
$token = MicropubToken::where('token_hash', hash('sha256', $rawToken))
->whereNull('revoked_at')
->first();
if (! $token) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->invalidTokenResponse();
}
if ($tokenData->claims()->has('scope') === false) {
if ($token->scope === '') {
$micropubResponses = new MicropubResponses;
return $micropubResponses->tokenHasNoScopeResponse();
@ -56,26 +54,10 @@ class VerifyMicropubToken
return $next($request->merge([
'access_token' => $rawToken,
'token_data' => [
'me' => $tokenData->claims()->get('me'),
'scope' => $tokenData->claims()->get('scope'),
'client_id' => $tokenData->claims()->get('client_id'),
'me' => $token->me,
'scope' => $token->scope,
'client_id' => $token->client_id,
],
]));
}
/**
* Check the token signature is valid.
*/
private function validateToken(string $bearerToken): Token
{
$config = resolve(Configuration::class);
$token = $config->parser()->parse($bearerToken);
$constraints = $config->validationConstraints();
$config->validator()->assert($token, ...$constraints);
return $token;
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
#[Table('micropub_tokens')]
#[Fillable(['token_hash', 'client_id', 'me', 'scope'])]
class MicropubToken extends Model
{
protected function casts(): array
{
return [
'revoked_at' => 'datetime',
];
}
public function revoke(): void
{
$this->forceFill(['revoked_at' => now()])->save();
}
protected function isRevoked(): Attribute
{
return Attribute::make(
get: fn () => $this->revoked_at !== null,
);
}
}

View file

@ -7,10 +7,6 @@ use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Validation\Constraint\SignedWith;
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
@ -53,17 +49,6 @@ class AppServiceProvider extends ServiceProvider
);
});
// Configure JWT builder
$this->app->bind('Lcobucci\JWT\Configuration', function () {
$key = InMemory::plainText(config('app.key'));
$config = Configuration::forSymmetricSigner(new Sha256, $key);
$config->setValidationConstraints(new SignedWith(new Sha256, $key));
return $config;
});
// Configure HtmlSanitizer
$this->app->bind(HtmlSanitizer::class, function () {
return new HtmlSanitizer(

View file

@ -5,28 +5,26 @@ declare(strict_types=1);
namespace App\Services;
use App\Jobs\AddClientToDatabase;
use DateTimeImmutable;
use Lcobucci\JWT\Configuration;
use App\Models\MicropubToken;
class TokenService
{
/**
* Generate a JWT token.
* Generate a new bearer token.
*/
public function getNewToken(array $data): string
{
$config = resolve(Configuration::class);
$token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$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(), $config->signingKey());
MicropubToken::create([
'token_hash' => hash('sha256', $token),
'client_id' => $data['client_id'],
'me' => $data['me'],
'scope' => $data['scope'],
]);
dispatch(new AddClientToDatabase($data['client_id']));
return $token->toString();
return $token;
}
}

View file

@ -22,7 +22,6 @@
"laravel/horizon": "^5.0",
"laravel/scout": "^10.1",
"laravel/tinker": "^3.0",
"lcobucci/jwt": "^5.0",
"league/commonmark": "^2.0",
"league/flysystem-aws-s3-v3": "^3.0",
"mf2/mf2": "~0.3",

75
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "23983a4e6a8e79cb9636fe8f0e604eb7",
"content-hash": "a2842cf95580a08ad759a92d74fae3b4",
"packages": [
{
"name": "aws/aws-crt-php",
@ -2431,79 +2431,6 @@
},
"time": "2026-03-17T14:54:13+00:00"
},
{
"name": "lcobucci/jwt",
"version": "5.6.0",
"source": {
"type": "git",
"url": "https://github.com/lcobucci/jwt.git",
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e",
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"ext-sodium": "*",
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"psr/clock": "^1.0"
},
"require-dev": {
"infection/infection": "^0.29",
"lcobucci/clock": "^3.2",
"lcobucci/coding-standard": "^11.0",
"phpbench/phpbench": "^1.2",
"phpstan/extension-installer": "^1.2",
"phpstan/phpstan": "^1.10.7",
"phpstan/phpstan-deprecation-rules": "^1.1.3",
"phpstan/phpstan-phpunit": "^1.3.10",
"phpstan/phpstan-strict-rules": "^1.5.0",
"phpunit/phpunit": "^11.1"
},
"suggest": {
"lcobucci/clock": ">= 3.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Lcobucci\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Luís Cobucci",
"email": "lcobucci@gmail.com",
"role": "Developer"
}
],
"description": "A simple library to work with JSON Web Token and JSON Web Signature",
"keywords": [
"JWS",
"jwt"
],
"support": {
"issues": "https://github.com/lcobucci/jwt/issues",
"source": "https://github.com/lcobucci/jwt/tree/5.6.0"
},
"funding": [
{
"url": "https://github.com/lcobucci",
"type": "github"
},
{
"url": "https://www.patreon.com/lcobucci",
"type": "patreon"
}
],
"time": "2025-10-17T11:30:53+00:00"
},
{
"name": "league/commonmark",
"version": "2.8.3",

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('micropub_tokens', function (Blueprint $table) {
$table->id();
$table->string('token_hash')->unique();
$table->string('client_id');
$table->string('me');
$table->string('scope');
$table->timestamp('revoked_at')->nullable();
$table->timestamps();
$table->index('client_id');
});
}
public function down(): void
{
Schema::dropIfExists('micropub_tokens');
}
};

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));
}
}