[MTM] Initial token re-work #117

Merged
jonny merged 5 commits from develop into main 2026-08-14 11:42:18 +02:00
5 changed files with 125 additions and 12 deletions
Showing only changes of commit 24da24a677 - Show all commits

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
Jonny Barnes 2026-08-13 16:44:31 +01:00
Signed by: jonny
SSH key fingerprint: SHA256:CTuSlns5U7qlD9jqHvtnVmfYV3Zwl2Z7WnJ4/dqOaL8

View file

@ -26,9 +26,9 @@ class IndieAuthController extends Controller
'authorization_endpoint' => route('indieauth.start'), 'authorization_endpoint' => route('indieauth.start'),
'token_endpoint' => route('indieauth.token'), 'token_endpoint' => route('indieauth.token'),
'revocation_endpoint' => route('indieauth.revocation'), 'revocation_endpoint' => route('indieauth.revocation'),
'introspection_endpoint' => route('indieauth.introspection'),
'introspection_endpoint_auth_methods_supported' => ['Bearer'],
'code_challenge_methods_supported' => ['S256'], '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 public function processRevocationRequest(Request $request): JsonResponse
{ {
$token = $request->get('token', ''); MicropubToken::findActive($request->get('token', ''))?->revoke();
if ($token !== '') {
MicropubToken::where('token_hash', hash('sha256', $token))
->whereNull('revoked_at')
->first()
?->revoke();
}
return response()->json([], 200); 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 protected function isValidRedirectUri(string $clientId, string $redirectUri): bool
{ {
// If client_id is not a valid URL, then it's not valid // If client_id is not a valid URL, then it's not valid

View file

@ -35,9 +35,7 @@ class VerifyMicropubToken
], 401); ], 401);
} }
$token = MicropubToken::where('token_hash', hash('sha256', $rawToken)) $token = MicropubToken::findActive($rawToken);
->whereNull('revoked_at')
->first();
if (! $token) { if (! $token) {
$micropubResponses = new MicropubResponses; $micropubResponses = new MicropubResponses;

View file

@ -25,6 +25,20 @@ class MicropubToken extends Model
$this->forceFill(['revoked_at' => now()])->save(); $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 protected function isRevoked(): Attribute
{ {
return Attribute::make( return Attribute::make(

View file

@ -213,6 +213,7 @@ Route::post('auth/confirm', [IndieAuthController::class, 'confirm'])->middleware
Route::post('auth', [IndieAuthController::class, 'processCodeExchange']); Route::post('auth', [IndieAuthController::class, 'processCodeExchange']);
Route::post('token', [IndieAuthController::class, 'processTokenRequest'])->name('indieauth.token'); Route::post('token', [IndieAuthController::class, 'processTokenRequest'])->name('indieauth.token');
Route::post('revocation', [IndieAuthController::class, 'processRevocationRequest'])->name('indieauth.revocation'); Route::post('revocation', [IndieAuthController::class, 'processRevocationRequest'])->name('indieauth.revocation');
Route::post('introspect', [IndieAuthController::class, 'processIntrospectionRequest'])->name('indieauth.introspection');
// Micropub Endpoints // Micropub Endpoints
Route::get('api/post', [MicropubController::class, 'get'])->middleware(VerifyMicropubToken::class); Route::get('api/post', [MicropubController::class, 'get'])->middleware(VerifyMicropubToken::class);

View file

@ -719,4 +719,80 @@ class IndieAuthTest extends TestCase
$response->assertStatus(200); $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]);
}
} }