From 9c9a6392c8220fd44fdee4de6a616fd12c7bf57b Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Thu, 13 Aug 2026 16:13:11 +0100 Subject: [PATCH] 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); + } }