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