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