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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L
This commit is contained in:
Jonny Barnes 2026-08-13 16:44:31 +01:00
commit 24da24a677
Signed by: jonny
SSH key fingerprint: SHA256:CTuSlns5U7qlD9jqHvtnVmfYV3Zwl2Z7WnJ4/dqOaL8
5 changed files with 125 additions and 12 deletions

View file

@ -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]);
}
}