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 Date: Thu, 13 Aug 2026 16:13:11 +0100 Subject: [PATCH 2/5] Add IndieAuth token revocation endpoint (RFC 7009) Implements the current IndieAuth spec's dedicated /revocation endpoint so clients can self-revoke a token (e.g. on user sign-out), rather than only supporting revocation via the admin side. Always responds 200 per spec, whether the token was found or not, so callers can't use it to probe token validity. Skips the legacy action=revoke-on-/token fallback the spec mentions for older clients, since the only real client here is already being updated to use the current endpoint. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L --- app/Http/Controllers/IndieAuthController.php | 22 +++++++++++++++ app/Http/Middleware/LinkHeadersMiddleware.php | 1 + routes/web.php | 1 + tests/Feature/HeaderLinkTest.php | 5 ++-- tests/Feature/IndieAuthTest.php | 27 +++++++++++++++++++ 5 files changed, 54 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/IndieAuthController.php b/app/Http/Controllers/IndieAuthController.php index eeb59770..db62aa98 100644 --- a/app/Http/Controllers/IndieAuthController.php +++ b/app/Http/Controllers/IndieAuthController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Controllers; +use App\Models\MicropubToken; use App\Services\TokenService; use GuzzleHttp\Psr7\Uri; use Illuminate\Http\JsonResponse; @@ -24,6 +25,7 @@ class IndieAuthController extends Controller 'issuer' => config('app.url'), 'authorization_endpoint' => route('indieauth.start'), 'token_endpoint' => route('indieauth.token'), + 'revocation_endpoint' => route('indieauth.revocation'), 'code_challenge_methods_supported' => ['S256'], // 'introspection_endpoint' => route('indieauth.introspection'), // 'introspection_endpoint_auth_methods_supported' => ['none'], @@ -178,6 +180,26 @@ class IndieAuthController extends Controller ]); } + /** + * Process a POST request to the IndieAuth revocation endpoint (RFC 7009). + * + * Per spec this always returns HTTP 200, whether the token was revoked, + * unknown, or already revoked, so callers can't probe token validity. + */ + public function processRevocationRequest(Request $request): JsonResponse + { + $token = $request->get('token', ''); + + if ($token !== '') { + MicropubToken::where('token_hash', hash('sha256', $token)) + ->whereNull('revoked_at') + ->first() + ?->revoke(); + } + + return response()->json([], 200); + } + 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/LinkHeadersMiddleware.php b/app/Http/Middleware/LinkHeadersMiddleware.php index b9e55139..0a280d44 100644 --- a/app/Http/Middleware/LinkHeadersMiddleware.php +++ b/app/Http/Middleware/LinkHeadersMiddleware.php @@ -17,6 +17,7 @@ class LinkHeadersMiddleware $response->header('Link', '<'.route('indieauth.metadata').'>; rel="indieauth-metadata"', false); $response->header('Link', '<'.route('indieauth.start').'>; rel="authorization_endpoint"', false); $response->header('Link', '<'.route('indieauth.token').'>; rel="token_endpoint"', false); + $response->header('Link', '<'.route('indieauth.revocation').'>; rel="revocation_endpoint"', false); $response->header('Link', '<'.route('micropub-endpoint').'>; rel="micropub"', false); $response->header('Link', '<'.route('webmention-endpoint').'>; rel="webmention"', false); diff --git a/routes/web.php b/routes/web.php index 953b51ac..fbba6329 100644 --- a/routes/web.php +++ b/routes/web.php @@ -205,6 +205,7 @@ Route::get('auth', [IndieAuthController::class, 'start'])->middleware(MyAuthMidd Route::post('auth/confirm', [IndieAuthController::class, 'confirm'])->middleware(MyAuthMiddleware::class); Route::post('auth', [IndieAuthController::class, 'processCodeExchange']); Route::post('token', [IndieAuthController::class, 'processTokenRequest'])->name('indieauth.token'); +Route::post('revocation', [IndieAuthController::class, 'processRevocationRequest'])->name('indieauth.revocation'); // Micropub Endpoints Route::get('api/post', [MicropubController::class, 'get'])->middleware(VerifyMicropubToken::class); diff --git a/tests/Feature/HeaderLinkTest.php b/tests/Feature/HeaderLinkTest.php index 874731a5..8a68d88f 100644 --- a/tests/Feature/HeaderLinkTest.php +++ b/tests/Feature/HeaderLinkTest.php @@ -19,7 +19,8 @@ class HeaderLinkTest extends TestCase $this->assertSame('<'.config('app.url').'/.well-known/indieauth-server>; rel="indieauth-metadata"', $linkHeaders[0]); $this->assertSame('<'.config('app.url').'/auth>; rel="authorization_endpoint"', $linkHeaders[1]); $this->assertSame('<'.config('app.url').'/token>; rel="token_endpoint"', $linkHeaders[2]); - $this->assertSame('<'.config('app.url').'/api/post>; rel="micropub"', $linkHeaders[3]); - $this->assertSame('<'.config('app.url').'/webmention>; rel="webmention"', $linkHeaders[4]); + $this->assertSame('<'.config('app.url').'/revocation>; rel="revocation_endpoint"', $linkHeaders[3]); + $this->assertSame('<'.config('app.url').'/api/post>; rel="micropub"', $linkHeaders[4]); + $this->assertSame('<'.config('app.url').'/webmention>; rel="webmention"', $linkHeaders[5]); } } diff --git a/tests/Feature/IndieAuthTest.php b/tests/Feature/IndieAuthTest.php index b32f4420..c7420e6d 100644 --- a/tests/Feature/IndieAuthTest.php +++ b/tests/Feature/IndieAuthTest.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace Tests\Feature; +use App\Models\MicropubToken; use App\Models\User; +use App\Services\TokenService; use GuzzleHttp\Psr7\Uri; use GuzzleHttp\Psr7\UriResolver; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -692,4 +694,29 @@ class IndieAuthTest extends TestCase 'me' => config('app.url'), ]); } + + #[Test] + public function it_should_revoke_a_known_token(): void + { + $token = resolve(TokenService::class)->getNewToken([ + 'me' => config('app.url'), + 'client_id' => 'https://app.example.com', + 'scope' => 'create', + ]); + + $response = $this->post('/revocation', ['token' => $token]); + $response->assertStatus(200); + + $this->assertTrue( + MicropubToken::where('token_hash', hash('sha256', $token))->firstOrFail()->isRevoked + ); + } + + #[Test] + public function it_should_return200_for_an_unknown_token(): void + { + $response = $this->post('/revocation', ['token' => bin2hex(random_bytes(32))]); + + $response->assertStatus(200); + } } -- 2.55.0 From 9d6cf6c815e5fd1f4eb0e41c7dff0c89e04cf47f Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Thu, 13 Aug 2026 16:18:52 +0100 Subject: [PATCH 3/5] Add admin page to list and revoke Micropub tokens Gives a way to actually use the revocation capability built up over the last few commits from the admin side, not just self-service via the client. Lists client_id/scope/issue time per token (never the raw token itself, since only its hash is stored) with a revoke button for active ones. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L --- .../Controllers/Admin/TokensController.php | 33 +++++++ resources/views/admin/tokens/index.blade.php | 27 ++++++ resources/views/admin/welcome.blade.php | 5 ++ routes/web.php | 7 ++ tests/Feature/Admin/TokensTest.php | 87 +++++++++++++++++++ 5 files changed, 159 insertions(+) create mode 100644 app/Http/Controllers/Admin/TokensController.php create mode 100644 resources/views/admin/tokens/index.blade.php create mode 100644 tests/Feature/Admin/TokensTest.php diff --git a/app/Http/Controllers/Admin/TokensController.php b/app/Http/Controllers/Admin/TokensController.php new file mode 100644 index 00000000..1b5348f9 --- /dev/null +++ b/app/Http/Controllers/Admin/TokensController.php @@ -0,0 +1,33 @@ +get(); + + return view('admin.tokens.index', compact('tokens')); + } + + /** + * Revoke a Micropub token. + */ + public function revoke(MicropubToken $token): RedirectResponse + { + $token->revoke(); + + return redirect('/admin/tokens'); + } +} diff --git a/resources/views/admin/tokens/index.blade.php b/resources/views/admin/tokens/index.blade.php new file mode 100644 index 00000000..cb7edda9 --- /dev/null +++ b/resources/views/admin/tokens/index.blade.php @@ -0,0 +1,27 @@ +@extends('master') + +@section('title')List Tokens « Admin CP « @stop + +@section('content') +

Micropub Tokens

+ @if($tokens->isEmpty()) +

No tokens have been issued.

+ @else +
    + @foreach($tokens as $token) +
  • + {{ $token->client_id }} — scope: {{ $token->scope }} — issued {{ $token->created_at->diffForHumans() }} + @if($token->isRevoked) + — revoked {{ $token->revoked_at->diffForHumans() }} + @else +
    + {{ csrf_field() }} + {{ method_field('PUT') }} + +
    + @endif +
  • + @endforeach +
+ @endif +@stop diff --git a/resources/views/admin/welcome.blade.php b/resources/views/admin/welcome.blade.php index 269ccdc5..663cfdc4 100644 --- a/resources/views/admin/welcome.blade.php +++ b/resources/views/admin/welcome.blade.php @@ -47,6 +47,11 @@ or edit them.

+

Tokens

+

+ View and revoke issued Micropub tokens. +

+

Bio

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 Date: Thu, 13 Aug 2026 17:03:43 +0100 Subject: [PATCH 5/5] Fix CSRF exemption and array-input crash on revocation/introspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Opus code review of the branch caught two real bugs the test suite structurally couldn't see: - /revocation and /introspect were never added to bootstrap/app.php's CSRF except list, so both were fully broken (403) for any real external client, despite every feature test passing — CSRF verification is short-circuited entirely while running tests. Verified live against the running app before and after the fix, and added a regression test that asserts against the actual configured exemptions rather than relying on request-time behavior that tests can't exercise. - An array-shaped `token` param (e.g. token[]=a&token[]=b) crashed both endpoints with a 500, since this app promotes PHP warnings ("Array to string conversion") to exceptions. Fixed at the shared root, MicropubToken::findActive(), which also closes the same latent hole in VerifyMicropubToken's access_token param that predates this branch. Verified live and covered with regression tests. Also applied the review's lower-severity findings: added the missing introspection_endpoint Link header and metadata test assertions, removed the now-dead is_string($scopes) array branch in the Micropub handlers and media controller (scope is unconditionally a string from the DB now, this guarded against a JWT-array-claim shape that can no longer occur), dropped a redundant #[Table] model attribute, sized token_hash to its actual 64-char length, and removed a one-off inline style in the admin view. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L --- app/Http/Controllers/IndieAuthController.php | 6 ++-- .../Controllers/MicropubMediaController.php | 8 ++--- app/Http/Middleware/LinkHeadersMiddleware.php | 1 + app/Models/MicropubToken.php | 12 ++++--- .../Micropub/Handlers/CardHandler.php | 4 +-- .../Micropub/Handlers/EntryHandler.php | 4 +-- .../Micropub/Handlers/UpdateHandler.php | 4 +-- bootstrap/app.php | 6 ++-- ...13_120924_create_micropub_tokens_table.php | 2 +- resources/views/admin/tokens/index.blade.php | 2 +- tests/Feature/CsrfExemptionsTest.php | 29 +++++++++++++++++ tests/Feature/HeaderLinkTest.php | 5 +-- tests/Feature/IndieAuthTest.php | 32 +++++++++++++++++-- tests/Feature/TokenServiceTest.php | 14 ++++++++ 14 files changed, 98 insertions(+), 31 deletions(-) create mode 100644 tests/Feature/CsrfExemptionsTest.php diff --git a/app/Http/Controllers/IndieAuthController.php b/app/Http/Controllers/IndieAuthController.php index bff8ebee..a795bce8 100644 --- a/app/Http/Controllers/IndieAuthController.php +++ b/app/Http/Controllers/IndieAuthController.php @@ -188,7 +188,7 @@ class IndieAuthController extends Controller */ public function processRevocationRequest(Request $request): JsonResponse { - MicropubToken::findActive($request->get('token', ''))?->revoke(); + MicropubToken::findActive($request->get('token'))?->revoke(); return response()->json([], 200); } @@ -205,11 +205,11 @@ class IndieAuthController extends Controller */ public function processIntrospectionRequest(Request $request): JsonResponse { - if (! MicropubToken::findActive((string) $request->bearerToken())) { + if (! MicropubToken::findActive($request->bearerToken())) { return response()->json([], 401); } - $token = MicropubToken::findActive((string) $request->get('token', '')); + $token = MicropubToken::findActive($request->get('token')); if (! $token) { return response()->json(['active' => false]); diff --git a/app/Http/Controllers/MicropubMediaController.php b/app/Http/Controllers/MicropubMediaController.php index da7c7dc2..d9f8ea32 100644 --- a/app/Http/Controllers/MicropubMediaController.php +++ b/app/Http/Controllers/MicropubMediaController.php @@ -26,9 +26,7 @@ class MicropubMediaController extends Controller $tokenData = $request->input('token_data'); $scopes = $tokenData['scope']; - if (is_string($scopes)) { - $scopes = explode(' ', $scopes); - } + $scopes = explode(' ', $scopes); if (! in_array('create', $scopes, true)) { return (new MicropubResponses)->insufficientScopeResponse(); } @@ -84,9 +82,7 @@ class MicropubMediaController extends Controller $tokenData = $request->input('token_data'); $scopes = $tokenData['scope']; - if (is_string($scopes)) { - $scopes = explode(' ', $scopes); - } + $scopes = explode(' ', $scopes); if (! in_array('create', $scopes, true)) { return (new MicropubResponses)->insufficientScopeResponse(); } diff --git a/app/Http/Middleware/LinkHeadersMiddleware.php b/app/Http/Middleware/LinkHeadersMiddleware.php index 0a280d44..e2810f87 100644 --- a/app/Http/Middleware/LinkHeadersMiddleware.php +++ b/app/Http/Middleware/LinkHeadersMiddleware.php @@ -18,6 +18,7 @@ class LinkHeadersMiddleware $response->header('Link', '<'.route('indieauth.start').'>; rel="authorization_endpoint"', false); $response->header('Link', '<'.route('indieauth.token').'>; rel="token_endpoint"', false); $response->header('Link', '<'.route('indieauth.revocation').'>; rel="revocation_endpoint"', false); + $response->header('Link', '<'.route('indieauth.introspection').'>; rel="introspection_endpoint"', false); $response->header('Link', '<'.route('micropub-endpoint').'>; rel="micropub"', false); $response->header('Link', '<'.route('webmention-endpoint').'>; rel="webmention"', false); diff --git a/app/Models/MicropubToken.php b/app/Models/MicropubToken.php index e85cb206..c4df41bc 100644 --- a/app/Models/MicropubToken.php +++ b/app/Models/MicropubToken.php @@ -5,11 +5,9 @@ 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 { @@ -26,11 +24,15 @@ class MicropubToken extends Model } /** - * Find the active (non-revoked) token matching a raw bearer token string. + * Find the active (non-revoked) token matching a raw bearer token value. + * + * Accepts mixed because callers pass request input directly, which PHP + * lets be an array (e.g. a client sending token[]=a) - casting that to + * string would throw, so anything non-string is just treated as absent. */ - public static function findActive(string $rawToken): ?self + public static function findActive(mixed $rawToken): ?self { - if ($rawToken === '') { + if (! is_string($rawToken) || $rawToken === '') { return null; } diff --git a/app/Services/Micropub/Handlers/CardHandler.php b/app/Services/Micropub/Handlers/CardHandler.php index 02e3a066..6b24f21b 100644 --- a/app/Services/Micropub/Handlers/CardHandler.php +++ b/app/Services/Micropub/Handlers/CardHandler.php @@ -24,9 +24,7 @@ class CardHandler implements MicropubHandlerInterface assert($data instanceof CardData); $scopes = $data->tokenData['scope']; - if (is_string($scopes)) { - $scopes = explode(' ', $scopes); - } + $scopes = explode(' ', $scopes); if (! in_array('create', $scopes, true)) { throw new InvalidTokenScopeException; diff --git a/app/Services/Micropub/Handlers/EntryHandler.php b/app/Services/Micropub/Handlers/EntryHandler.php index ef9740f2..48bbb550 100644 --- a/app/Services/Micropub/Handlers/EntryHandler.php +++ b/app/Services/Micropub/Handlers/EntryHandler.php @@ -27,9 +27,7 @@ class EntryHandler implements MicropubHandlerInterface assert($data instanceof EntryData); $scopes = $data->tokenData['scope']; - if (is_string($scopes)) { - $scopes = explode(' ', $scopes); - } + $scopes = explode(' ', $scopes); if (! in_array('create', $scopes, true)) { throw new InvalidTokenScopeException; diff --git a/app/Services/Micropub/Handlers/UpdateHandler.php b/app/Services/Micropub/Handlers/UpdateHandler.php index 49f86063..136a0840 100644 --- a/app/Services/Micropub/Handlers/UpdateHandler.php +++ b/app/Services/Micropub/Handlers/UpdateHandler.php @@ -30,9 +30,7 @@ class UpdateHandler implements MicropubHandlerInterface assert($data instanceof UpdateData); $scopes = $data->tokenData['scope']; - if (is_string($scopes)) { - $scopes = explode(' ', $scopes); - } + $scopes = explode(' ', $scopes); if (! in_array('update', $scopes, true)) { throw new InvalidTokenScopeException; diff --git a/bootstrap/app.php b/bootstrap/app.php index 9c73bdb9..e29a3598 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -17,8 +17,10 @@ return Application::configure(basePath: dirname(__DIR__)) ->append(LinkHeadersMiddleware::class) ->preventRequestForgery( except: [ - 'auth', // This is the IndieAuth auth endpoint - 'token', // This is the IndieAuth token endpoint + 'auth', // This is the IndieAuth auth endpoint + 'token', // This is the IndieAuth token endpoint + 'revocation', // This is the IndieAuth revocation endpoint + 'introspect', // This is the IndieAuth introspection endpoint 'api/post', 'api/media', 'micropub/places', 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 index cb37f28b..e336912f 100644 --- a/database/migrations/2026_08_13_120924_create_micropub_tokens_table.php +++ b/database/migrations/2026_08_13_120924_create_micropub_tokens_table.php @@ -12,7 +12,7 @@ return new class extends Migration { Schema::create('micropub_tokens', function (Blueprint $table) { $table->id(); - $table->string('token_hash')->unique(); + $table->string('token_hash', 64)->unique(); $table->string('client_id'); $table->string('me'); $table->string('scope'); diff --git a/resources/views/admin/tokens/index.blade.php b/resources/views/admin/tokens/index.blade.php index cb7edda9..8836ab07 100644 --- a/resources/views/admin/tokens/index.blade.php +++ b/resources/views/admin/tokens/index.blade.php @@ -14,7 +14,7 @@ @if($token->isRevoked) — revoked {{ $token->revoked_at->diffForHumans() }} @else -

+ {{ csrf_field() }} {{ method_field('PUT') }} diff --git a/tests/Feature/CsrfExemptionsTest.php b/tests/Feature/CsrfExemptionsTest.php new file mode 100644 index 00000000..c03a3c2c --- /dev/null +++ b/tests/Feature/CsrfExemptionsTest.php @@ -0,0 +1,29 @@ +app->make(PreventRequestForgery::class)->getExcludedPaths(); + + foreach (['auth', 'token', 'revocation', 'introspect', 'api/post', 'api/media', 'micropub/places', 'webmention'] as $path) { + $this->assertContains($path, $exemptions); + } + } +} diff --git a/tests/Feature/HeaderLinkTest.php b/tests/Feature/HeaderLinkTest.php index 8a68d88f..3983b02c 100644 --- a/tests/Feature/HeaderLinkTest.php +++ b/tests/Feature/HeaderLinkTest.php @@ -20,7 +20,8 @@ class HeaderLinkTest extends TestCase $this->assertSame('<'.config('app.url').'/auth>; rel="authorization_endpoint"', $linkHeaders[1]); $this->assertSame('<'.config('app.url').'/token>; rel="token_endpoint"', $linkHeaders[2]); $this->assertSame('<'.config('app.url').'/revocation>; rel="revocation_endpoint"', $linkHeaders[3]); - $this->assertSame('<'.config('app.url').'/api/post>; rel="micropub"', $linkHeaders[4]); - $this->assertSame('<'.config('app.url').'/webmention>; rel="webmention"', $linkHeaders[5]); + $this->assertSame('<'.config('app.url').'/introspect>; rel="introspection_endpoint"', $linkHeaders[4]); + $this->assertSame('<'.config('app.url').'/api/post>; rel="micropub"', $linkHeaders[5]); + $this->assertSame('<'.config('app.url').'/webmention>; rel="webmention"', $linkHeaders[6]); } } diff --git a/tests/Feature/IndieAuthTest.php b/tests/Feature/IndieAuthTest.php index ba456748..08282228 100644 --- a/tests/Feature/IndieAuthTest.php +++ b/tests/Feature/IndieAuthTest.php @@ -29,9 +29,10 @@ class IndieAuthTest extends TestCase 'issuer' => config('app.url'), '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' => 'introspection_endpoint', - // 'introspection_endpoint_auth_methods_supported' => ['none'], ]); } @@ -795,4 +796,31 @@ class IndieAuthTest extends TestCase $response->assertStatus(200); $response->assertExactJson(['active' => false]); } + + #[Test] + public function revocation_does_not_error_on_an_array_shaped_token_param(): void + { + $response = $this->post('/revocation', ['token' => ['a', 'b']]); + + $response->assertStatus(200); + } + + #[Test] + public function introspection_does_not_error_on_an_array_shaped_token_param(): void + { + $callerToken = resolve(TokenService::class)->getNewToken([ + 'me' => config('app.url'), + 'client_id' => 'https://app.example.com', + 'scope' => 'create', + ]); + + $response = $this->post( + '/introspect', + ['token' => ['a', 'b']], + ['HTTP_Authorization' => 'Bearer '.$callerToken] + ); + + $response->assertStatus(200); + $response->assertExactJson(['active' => false]); + } } diff --git a/tests/Feature/TokenServiceTest.php b/tests/Feature/TokenServiceTest.php index 6643452d..91b9e81d 100644 --- a/tests/Feature/TokenServiceTest.php +++ b/tests/Feature/TokenServiceTest.php @@ -71,4 +71,18 @@ class TokenServiceTest extends TestCase 'error_description' => 'The provided token did not pass validation', ]); } + + /** + * Request input for a "string" field can be sent as an array + * (e.g. token[]=a&token[]=b). Casting that to string throws in this app + * (warnings are promoted to exceptions), so findActive() must guard + * against it rather than assume its caller already validated the type. + */ + #[Test] + public function find_active_treats_non_string_input_as_absent(): void + { + $this->assertNull(MicropubToken::findActive(['a', 'b'])); + $this->assertNull(MicropubToken::findActive(null)); + $this->assertNull(MicropubToken::findActive(123)); + } } -- 2.55.0