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

@ -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

View file

@ -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;

View file

@ -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(