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:
parent
9d6cf6c815
commit
24da24a677
5 changed files with 125 additions and 12 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue