From d5706b5f8f049c05cb216a460196e32cfc5b2e90 Mon Sep 17 00:00:00 2001
From: Jonny Barnes
Date: Thu, 13 Aug 2026 16:07:07 +0100
Subject: [PATCH 1/5] 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
Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L
---
app/Http/Controllers/MicropubController.php | 3 +-
app/Http/Middleware/VerifyMicropubToken.php | 38 +++-------
app/Models/MicropubToken.php | 34 +++++++++
app/Providers/AppServiceProvider.php | 15 ----
app/Services/TokenService.php | 22 +++---
composer.json | 1 -
composer.lock | 75 +------------------
...13_120924_create_micropub_tokens_table.php | 30 ++++++++
tests/Feature/TokenServiceTest.php | 35 +++++----
tests/TestToken.php | 52 +++++--------
10 files changed, 124 insertions(+), 181 deletions(-)
create mode 100644 app/Models/MicropubToken.php
create mode 100644 database/migrations/2026_08_13_120924_create_micropub_tokens_table.php
diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php
index c6008a9c..2df5d432 100644
--- a/app/Http/Controllers/MicropubController.php
+++ b/app/Http/Controllers/MicropubController.php
@@ -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([
diff --git a/app/Http/Middleware/VerifyMicropubToken.php b/app/Http/Middleware/VerifyMicropubToken.php
index 33d2cb12..e61cc67a 100644
--- a/app/Http/Middleware/VerifyMicropubToken.php
+++ b/app/Http/Middleware/VerifyMicropubToken.php
@@ -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;
- }
}
diff --git a/app/Models/MicropubToken.php b/app/Models/MicropubToken.php
new file mode 100644
index 00000000..231237f6
--- /dev/null
+++ b/app/Models/MicropubToken.php
@@ -0,0 +1,34 @@
+ 'datetime',
+ ];
+ }
+
+ public function revoke(): void
+ {
+ $this->forceFill(['revoked_at' => now()])->save();
+ }
+
+ protected function isRevoked(): Attribute
+ {
+ return Attribute::make(
+ get: fn () => $this->revoked_at !== null,
+ );
+ }
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 224472d1..68367a97 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -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(
diff --git a/app/Services/TokenService.php b/app/Services/TokenService.php
index 68a9293b..2941c28b 100644
--- a/app/Services/TokenService.php
+++ b/app/Services/TokenService.php
@@ -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;
}
}
diff --git a/composer.json b/composer.json
index 520d12e1..5871ee02 100644
--- a/composer.json
+++ b/composer.json
@@ -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",
diff --git a/composer.lock b/composer.lock
index 6af58a17..4c98c2cf 100644
--- a/composer.lock
+++ b/composer.lock
@@ -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",
diff --git a/database/migrations/2026_08_13_120924_create_micropub_tokens_table.php b/database/migrations/2026_08_13_120924_create_micropub_tokens_table.php
new file mode 100644
index 00000000..cb37f28b
--- /dev/null
+++ b/database/migrations/2026_08_13_120924_create_micropub_tokens_table.php
@@ -0,0 +1,30 @@
+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');
+ }
+};
diff --git a/tests/Feature/TokenServiceTest.php b/tests/Feature/TokenServiceTest.php
index 7fe9e854..6643452d 100644
--- a/tests/Feature/TokenServiceTest.php
+++ b/tests/Feature/TokenServiceTest.php
@@ -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]);
diff --git a/tests/TestToken.php b/tests/TestToken.php
index 287e2757..21e0b753 100644
--- a/tests/TestToken.php
+++ b/tests/TestToken.php
@@ -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));
}
}
--
2.55.0
From 9c9a6392c8220fd44fdee4de6a616fd12c7bf57b Mon Sep 17 00:00:00 2001
From: Jonny Barnes
No tokens have been issued.
+ @else ++ View and revoke issued Micropub tokens. +
+
Edit your bio.
diff --git a/routes/web.php b/routes/web.php
index fbba6329..e8924f32 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -10,6 +10,7 @@ use App\Http\Controllers\Admin\NotesController as AdminNotesController;
use App\Http\Controllers\Admin\PasskeysController;
use App\Http\Controllers\Admin\PlacesController as AdminPlacesController;
use App\Http\Controllers\Admin\SyndicationTargetsController;
+use App\Http\Controllers\Admin\TokensController;
use App\Http\Controllers\ArticlesController;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\BookmarksController;
@@ -147,6 +148,12 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () {
Route::delete('/{clientId}', [ClientsController::class, 'destroy']);
});
+ // Micropub Tokens
+ Route::prefix('tokens')->group(function () {
+ Route::get('/', [TokensController::class, 'index']);
+ Route::put('/{token}/revoke', [TokensController::class, 'revoke']);
+ });
+
// Bio
Route::prefix('bio')->group(function () {
Route::get('/', [BioController::class, 'show'])->name('admin.bio.show');
diff --git a/tests/Feature/Admin/TokensTest.php b/tests/Feature/Admin/TokensTest.php
new file mode 100644
index 00000000..0c415296
--- /dev/null
+++ b/tests/Feature/Admin/TokensTest.php
@@ -0,0 +1,87 @@
+get('/admin/tokens');
+ $response->assertRedirect();
+ }
+
+ #[Test]
+ public function index_lists_issued_tokens(): void
+ {
+ $user = User::factory()->make();
+ $token = MicropubToken::create([
+ 'token_hash' => hash('sha256', 'a-token'),
+ 'client_id' => 'https://quill.p3k.io',
+ 'me' => 'https://jonnybarnes.uk',
+ 'scope' => 'create update',
+ ]);
+
+ $response = $this->actingAs($user)->get('/admin/tokens');
+ $response->assertOk();
+ $response->assertSeeText($token->client_id);
+ }
+
+ #[Test]
+ public function revoke_requires_authentication(): void
+ {
+ $token = MicropubToken::create([
+ 'token_hash' => hash('sha256', 'a-token'),
+ 'client_id' => 'https://quill.p3k.io',
+ 'me' => 'https://jonnybarnes.uk',
+ 'scope' => 'create',
+ ]);
+
+ $response = $this->put("/admin/tokens/{$token->id}/revoke");
+ $response->assertRedirect();
+
+ $this->assertFalse($token->fresh()->isRevoked);
+ }
+
+ #[Test]
+ public function revoke_marks_the_token_as_revoked(): void
+ {
+ $user = User::factory()->make();
+ $token = MicropubToken::create([
+ 'token_hash' => hash('sha256', 'a-token'),
+ 'client_id' => 'https://quill.p3k.io',
+ 'me' => 'https://jonnybarnes.uk',
+ 'scope' => 'create',
+ ]);
+
+ $this->actingAs($user)->put("/admin/tokens/{$token->id}/revoke");
+
+ $this->assertTrue($token->fresh()->isRevoked);
+ }
+
+ #[Test]
+ public function revoke_redirects_to_index(): void
+ {
+ $user = User::factory()->make();
+ $token = MicropubToken::create([
+ 'token_hash' => hash('sha256', 'a-token'),
+ 'client_id' => 'https://quill.p3k.io',
+ 'me' => 'https://jonnybarnes.uk',
+ 'scope' => 'create',
+ ]);
+
+ $response = $this->actingAs($user)->put("/admin/tokens/{$token->id}/revoke");
+
+ $response->assertRedirect('/admin/tokens');
+ }
+}
--
2.55.0
From 24da24a677d49c8b189ccf340251ab79ddf2da44 Mon Sep 17 00:00:00 2001
From: Jonny Barnes
Date: Thu, 13 Aug 2026 16:44:31 +0100
Subject: [PATCH 4/5] Add IndieAuth token introspection endpoint (RFC 7662)
Lets a resource server (or a client checking its own token, via
self-introspection) verify a token's active/me/client_id/scope without
needing to be tightly coupled to this token endpoint. Requires the
caller to present their own currently-active token as authorization,
per spec's requirement that the endpoint MUST require some form of
authorization. Inactive tokens get back only {"active": false}, no
detail on why, matching the privacy stance already used for
revocation.
Pulled the hash-and-lookup-active-token logic (now needed a third
time) into MicropubToken::findActive(), used by this, the revocation
endpoint, and VerifyMicropubToken.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L
---
app/Http/Controllers/IndieAuthController.php | 44 +++++++++---
app/Http/Middleware/VerifyMicropubToken.php | 4 +-
app/Models/MicropubToken.php | 14 ++++
routes/web.php | 1 +
tests/Feature/IndieAuthTest.php | 76 ++++++++++++++++++++
5 files changed, 126 insertions(+), 13 deletions(-)
diff --git a/app/Http/Controllers/IndieAuthController.php b/app/Http/Controllers/IndieAuthController.php
index db62aa98..bff8ebee 100644
--- a/app/Http/Controllers/IndieAuthController.php
+++ b/app/Http/Controllers/IndieAuthController.php
@@ -26,9 +26,9 @@ class IndieAuthController extends Controller
'authorization_endpoint' => route('indieauth.start'),
'token_endpoint' => route('indieauth.token'),
'revocation_endpoint' => route('indieauth.revocation'),
+ 'introspection_endpoint' => route('indieauth.introspection'),
+ 'introspection_endpoint_auth_methods_supported' => ['Bearer'],
'code_challenge_methods_supported' => ['S256'],
- // 'introspection_endpoint' => route('indieauth.introspection'),
- // 'introspection_endpoint_auth_methods_supported' => ['none'],
]);
}
@@ -188,18 +188,42 @@ class IndieAuthController extends Controller
*/
public function processRevocationRequest(Request $request): JsonResponse
{
- $token = $request->get('token', '');
-
- if ($token !== '') {
- MicropubToken::where('token_hash', hash('sha256', $token))
- ->whereNull('revoked_at')
- ->first()
- ?->revoke();
- }
+ MicropubToken::findActive($request->get('token', ''))?->revoke();
return response()->json([], 200);
}
+ /**
+ * Process a POST request to the IndieAuth token introspection endpoint
+ * (RFC 7662, extended by IndieAuth to require the `me` property).
+ *
+ * The caller must itself present a currently-active token as a Bearer
+ * credential to use this endpoint, per spec ("MUST also require some
+ * form of authorization"). Per spec, an inactive token being introspected
+ * still gets a 200 response containing only `active: false` - no other
+ * information about why it's inactive is given.
+ */
+ public function processIntrospectionRequest(Request $request): JsonResponse
+ {
+ if (! MicropubToken::findActive((string) $request->bearerToken())) {
+ return response()->json([], 401);
+ }
+
+ $token = MicropubToken::findActive((string) $request->get('token', ''));
+
+ if (! $token) {
+ return response()->json(['active' => false]);
+ }
+
+ return response()->json([
+ 'active' => true,
+ 'me' => $token->me,
+ 'client_id' => $token->client_id,
+ 'scope' => $token->scope,
+ 'iat' => $token->created_at->timestamp,
+ ]);
+ }
+
protected function isValidRedirectUri(string $clientId, string $redirectUri): bool
{
// If client_id is not a valid URL, then it's not valid
diff --git a/app/Http/Middleware/VerifyMicropubToken.php b/app/Http/Middleware/VerifyMicropubToken.php
index e61cc67a..530995ae 100644
--- a/app/Http/Middleware/VerifyMicropubToken.php
+++ b/app/Http/Middleware/VerifyMicropubToken.php
@@ -35,9 +35,7 @@ class VerifyMicropubToken
], 401);
}
- $token = MicropubToken::where('token_hash', hash('sha256', $rawToken))
- ->whereNull('revoked_at')
- ->first();
+ $token = MicropubToken::findActive($rawToken);
if (! $token) {
$micropubResponses = new MicropubResponses;
diff --git a/app/Models/MicropubToken.php b/app/Models/MicropubToken.php
index 231237f6..e85cb206 100644
--- a/app/Models/MicropubToken.php
+++ b/app/Models/MicropubToken.php
@@ -25,6 +25,20 @@ class MicropubToken extends Model
$this->forceFill(['revoked_at' => now()])->save();
}
+ /**
+ * Find the active (non-revoked) token matching a raw bearer token string.
+ */
+ public static function findActive(string $rawToken): ?self
+ {
+ if ($rawToken === '') {
+ return null;
+ }
+
+ return self::where('token_hash', hash('sha256', $rawToken))
+ ->whereNull('revoked_at')
+ ->first();
+ }
+
protected function isRevoked(): Attribute
{
return Attribute::make(
diff --git a/routes/web.php b/routes/web.php
index e8924f32..dd594480 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -213,6 +213,7 @@ Route::post('auth/confirm', [IndieAuthController::class, 'confirm'])->middleware
Route::post('auth', [IndieAuthController::class, 'processCodeExchange']);
Route::post('token', [IndieAuthController::class, 'processTokenRequest'])->name('indieauth.token');
Route::post('revocation', [IndieAuthController::class, 'processRevocationRequest'])->name('indieauth.revocation');
+Route::post('introspect', [IndieAuthController::class, 'processIntrospectionRequest'])->name('indieauth.introspection');
// Micropub Endpoints
Route::get('api/post', [MicropubController::class, 'get'])->middleware(VerifyMicropubToken::class);
diff --git a/tests/Feature/IndieAuthTest.php b/tests/Feature/IndieAuthTest.php
index c7420e6d..ba456748 100644
--- a/tests/Feature/IndieAuthTest.php
+++ b/tests/Feature/IndieAuthTest.php
@@ -719,4 +719,80 @@ class IndieAuthTest extends TestCase
$response->assertStatus(200);
}
+
+ #[Test]
+ public function introspection_requires_a_bearer_token(): void
+ {
+ $response = $this->post('/introspect', ['token' => 'irrelevant']);
+
+ $response->assertStatus(401);
+ }
+
+ #[Test]
+ public function introspection_rejects_a_revoked_bearer_token(): void
+ {
+ $callerToken = resolve(TokenService::class)->getNewToken([
+ 'me' => config('app.url'),
+ 'client_id' => 'https://app.example.com',
+ 'scope' => 'create',
+ ]);
+ MicropubToken::where('token_hash', hash('sha256', $callerToken))->firstOrFail()->revoke();
+
+ $response = $this->post(
+ '/introspect',
+ ['token' => 'irrelevant'],
+ ['HTTP_Authorization' => 'Bearer '.$callerToken]
+ );
+
+ $response->assertStatus(401);
+ }
+
+ #[Test]
+ public function introspection_returns_active_details_for_a_valid_token(): void
+ {
+ $callerToken = resolve(TokenService::class)->getNewToken([
+ 'me' => config('app.url'),
+ 'client_id' => 'https://app.example.com',
+ 'scope' => 'create',
+ ]);
+ $subjectToken = resolve(TokenService::class)->getNewToken([
+ 'me' => 'https://someone-else.example.com',
+ 'client_id' => 'https://quill.p3k.io',
+ 'scope' => 'create update',
+ ]);
+
+ $response = $this->post(
+ '/introspect',
+ ['token' => $subjectToken],
+ ['HTTP_Authorization' => 'Bearer '.$callerToken]
+ );
+
+ $response->assertStatus(200);
+ $response->assertJson([
+ 'active' => true,
+ 'me' => 'https://someone-else.example.com',
+ 'client_id' => 'https://quill.p3k.io',
+ 'scope' => 'create update',
+ ]);
+ $response->assertJsonStructure(['iat']);
+ }
+
+ #[Test]
+ public function introspection_returns_only_active_false_for_an_unknown_token(): void
+ {
+ $callerToken = resolve(TokenService::class)->getNewToken([
+ 'me' => config('app.url'),
+ 'client_id' => 'https://app.example.com',
+ 'scope' => 'create',
+ ]);
+
+ $response = $this->post(
+ '/introspect',
+ ['token' => bin2hex(random_bytes(32))],
+ ['HTTP_Authorization' => 'Bearer '.$callerToken]
+ );
+
+ $response->assertStatus(200);
+ $response->assertExactJson(['active' => false]);
+ }
}
--
2.55.0
From faf8e5c1ec6e1875fc27c840a87e5a08e14f31bd Mon Sep 17 00:00:00 2001
From: Jonny Barnes