Compare commits

..
Author SHA1 Message Date
faf8e5c1ec
Fix CSRF exemption and array-input crash on revocation/introspection
An Opus code review of the branch caught two real bugs the test suite
structurally couldn't see:

- /revocation and /introspect were never added to bootstrap/app.php's
  CSRF except list, so both were fully broken (403) for any real
  external client, despite every feature test passing — CSRF
  verification is short-circuited entirely while running tests.
  Verified live against the running app before and after the fix, and
  added a regression test that asserts against the actual configured
  exemptions rather than relying on request-time behavior that tests
  can't exercise.

- An array-shaped `token` param (e.g. token[]=a&token[]=b) crashed
  both endpoints with a 500, since this app promotes PHP warnings
  ("Array to string conversion") to exceptions. Fixed at the shared
  root, MicropubToken::findActive(), which also closes the same latent
  hole in VerifyMicropubToken's access_token param that predates this
  branch. Verified live and covered with regression tests.

Also applied the review's lower-severity findings: added the missing
introspection_endpoint Link header and metadata test assertions,
removed the now-dead is_string($scopes) array branch in the Micropub
handlers and media controller (scope is unconditionally a string from
the DB now, this guarded against a JWT-array-claim shape that can no
longer occur), dropped a redundant #[Table] model attribute, sized
token_hash to its actual 64-char length, and removed a one-off inline
style in the admin view.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L
2026-08-13 17:03:43 +01:00
24da24a677
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
2026-08-13 16:44:31 +01:00
9d6cf6c815
Add admin page to list and revoke Micropub tokens
Gives a way to actually use the revocation capability built up over
the last few commits from the admin side, not just self-service via
the client. Lists client_id/scope/issue time per token (never the raw
token itself, since only its hash is stored) with a revoke button for
active ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L
2026-08-13 16:18:52 +01:00
9c9a6392c8
Add IndieAuth token revocation endpoint (RFC 7009)
Implements the current IndieAuth spec's dedicated /revocation endpoint
so clients can self-revoke a token (e.g. on user sign-out), rather than
only supporting revocation via the admin side. Always responds 200 per
spec, whether the token was found or not, so callers can't use it to
probe token validity. Skips the legacy action=revoke-on-/token fallback
the spec mentions for older clients, since the only real client here is
already being updated to use the current endpoint.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L
2026-08-13 16:13:11 +01:00
d5706b5f8f
Replace JWT Micropub tokens with revocable opaque tokens
Tokens now store a hashed row in micropub_tokens instead of being
self-contained signed JWTs, so a leaked or unwanted token can actually
be revoked. Since revocation already requires a DB lookup on every
request, JWT's stateless-verification benefit was gone anyway, so this
also drops the lcobucci/jwt dependency entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L
2026-08-13 16:07:07 +01:00
25 changed files with 538 additions and 204 deletions

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\MicropubToken;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class TokensController extends Controller
{
/**
* Show a list of issued Micropub tokens.
*/
public function index(): View
{
$tokens = MicropubToken::latest()->get();
return view('admin.tokens.index', compact('tokens'));
}
/**
* Revoke a Micropub token.
*/
public function revoke(MicropubToken $token): RedirectResponse
{
$token->revoke();
return redirect('/admin/tokens');
}
}

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\MicropubToken;
use App\Services\TokenService;
use GuzzleHttp\Psr7\Uri;
use Illuminate\Http\JsonResponse;
@ -24,9 +25,10 @@ class IndieAuthController extends Controller
'issuer' => config('app.url'),
'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'],
]);
}
@ -178,6 +180,50 @@ class IndieAuthController extends Controller
]);
}
/**
* Process a POST request to the IndieAuth revocation endpoint (RFC 7009).
*
* Per spec this always returns HTTP 200, whether the token was revoked,
* unknown, or already revoked, so callers can't probe token validity.
*/
public function processRevocationRequest(Request $request): JsonResponse
{
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($request->bearerToken())) {
return response()->json([], 401);
}
$token = MicropubToken::findActive($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

@ -15,7 +15,6 @@ use App\Services\Micropub\MicropubHandlerRegistry;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Lcobucci\JWT\Token;
class MicropubController extends Controller
{
@ -135,7 +134,7 @@ class MicropubController extends Controller
}
// the default response is just to return the token data
/** @var Token $tokenData */
/** @var array $tokenData */
$tokenData = $request->input('token_data');
return response()->json([

View file

@ -26,9 +26,7 @@ class MicropubMediaController extends Controller
$tokenData = $request->input('token_data');
$scopes = $tokenData['scope'];
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
$scopes = explode(' ', $scopes);
if (! in_array('create', $scopes, true)) {
return (new MicropubResponses)->insufficientScopeResponse();
}
@ -84,9 +82,7 @@ class MicropubMediaController extends Controller
$tokenData = $request->input('token_data');
$scopes = $tokenData['scope'];
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
$scopes = explode(' ', $scopes);
if (! in_array('create', $scopes, true)) {
return (new MicropubResponses)->insufficientScopeResponse();
}

View file

@ -17,6 +17,8 @@ class LinkHeadersMiddleware
$response->header('Link', '<'.route('indieauth.metadata').'>; rel="indieauth-metadata"', false);
$response->header('Link', '<'.route('indieauth.start').'>; rel="authorization_endpoint"', false);
$response->header('Link', '<'.route('indieauth.token').'>; rel="token_endpoint"', false);
$response->header('Link', '<'.route('indieauth.revocation').'>; rel="revocation_endpoint"', false);
$response->header('Link', '<'.route('indieauth.introspection').'>; rel="introspection_endpoint"', false);
$response->header('Link', '<'.route('micropub-endpoint').'>; rel="micropub"', false);
$response->header('Link', '<'.route('webmention-endpoint').'>; rel="webmention"', false);

View file

@ -5,13 +5,9 @@ declare(strict_types=1);
namespace App\Http\Middleware;
use App\Http\Responses\MicropubResponses;
use App\Models\MicropubToken;
use Closure;
use Illuminate\Http\Request;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Encoding\CannotDecodeContent;
use Lcobucci\JWT\Token;
use Lcobucci\JWT\Token\InvalidTokenStructure;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use Symfony\Component\HttpFoundation\Response;
class VerifyMicropubToken
@ -39,15 +35,15 @@ class VerifyMicropubToken
], 401);
}
try {
$tokenData = $this->validateToken($rawToken);
} catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) {
$token = MicropubToken::findActive($rawToken);
if (! $token) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->invalidTokenResponse();
}
if ($tokenData->claims()->has('scope') === false) {
if ($token->scope === '') {
$micropubResponses = new MicropubResponses;
return $micropubResponses->tokenHasNoScopeResponse();
@ -56,26 +52,10 @@ class VerifyMicropubToken
return $next($request->merge([
'access_token' => $rawToken,
'token_data' => [
'me' => $tokenData->claims()->get('me'),
'scope' => $tokenData->claims()->get('scope'),
'client_id' => $tokenData->claims()->get('client_id'),
'me' => $token->me,
'scope' => $token->scope,
'client_id' => $token->client_id,
],
]));
}
/**
* Check the token signature is valid.
*/
private function validateToken(string $bearerToken): Token
{
$config = resolve(Configuration::class);
$token = $config->parser()->parse($bearerToken);
$constraints = $config->validationConstraints();
$config->validator()->assert($token, ...$constraints);
return $token;
}
}

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['token_hash', 'client_id', 'me', 'scope'])]
class MicropubToken extends Model
{
protected function casts(): array
{
return [
'revoked_at' => 'datetime',
];
}
public function revoke(): void
{
$this->forceFill(['revoked_at' => now()])->save();
}
/**
* Find the active (non-revoked) token matching a raw bearer token value.
*
* Accepts mixed because callers pass request input directly, which PHP
* lets be an array (e.g. a client sending token[]=a) - casting that to
* string would throw, so anything non-string is just treated as absent.
*/
public static function findActive(mixed $rawToken): ?self
{
if (! is_string($rawToken) || $rawToken === '') {
return null;
}
return self::where('token_hash', hash('sha256', $rawToken))
->whereNull('revoked_at')
->first();
}
protected function isRevoked(): Attribute
{
return Attribute::make(
get: fn () => $this->revoked_at !== null,
);
}
}

View file

@ -7,10 +7,6 @@ use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Validation\Constraint\SignedWith;
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
@ -53,17 +49,6 @@ class AppServiceProvider extends ServiceProvider
);
});
// Configure JWT builder
$this->app->bind('Lcobucci\JWT\Configuration', function () {
$key = InMemory::plainText(config('app.key'));
$config = Configuration::forSymmetricSigner(new Sha256, $key);
$config->setValidationConstraints(new SignedWith(new Sha256, $key));
return $config;
});
// Configure HtmlSanitizer
$this->app->bind(HtmlSanitizer::class, function () {
return new HtmlSanitizer(

View file

@ -24,9 +24,7 @@ class CardHandler implements MicropubHandlerInterface
assert($data instanceof CardData);
$scopes = $data->tokenData['scope'];
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
$scopes = explode(' ', $scopes);
if (! in_array('create', $scopes, true)) {
throw new InvalidTokenScopeException;

View file

@ -27,9 +27,7 @@ class EntryHandler implements MicropubHandlerInterface
assert($data instanceof EntryData);
$scopes = $data->tokenData['scope'];
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
$scopes = explode(' ', $scopes);
if (! in_array('create', $scopes, true)) {
throw new InvalidTokenScopeException;

View file

@ -30,9 +30,7 @@ class UpdateHandler implements MicropubHandlerInterface
assert($data instanceof UpdateData);
$scopes = $data->tokenData['scope'];
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
$scopes = explode(' ', $scopes);
if (! in_array('update', $scopes, true)) {
throw new InvalidTokenScopeException;

View file

@ -5,28 +5,26 @@ declare(strict_types=1);
namespace App\Services;
use App\Jobs\AddClientToDatabase;
use DateTimeImmutable;
use Lcobucci\JWT\Configuration;
use App\Models\MicropubToken;
class TokenService
{
/**
* Generate a JWT token.
* Generate a new bearer token.
*/
public function getNewToken(array $data): string
{
$config = resolve(Configuration::class);
$token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$token = $config->builder()
->issuedAt(new DateTimeImmutable)
->withClaim('client_id', $data['client_id'])
->withClaim('me', $data['me'])
->withClaim('scope', $data['scope'])
->withClaim('nonce', bin2hex(random_bytes(8)))
->getToken($config->signer(), $config->signingKey());
MicropubToken::create([
'token_hash' => hash('sha256', $token),
'client_id' => $data['client_id'],
'me' => $data['me'],
'scope' => $data['scope'],
]);
dispatch(new AddClientToDatabase($data['client_id']));
return $token->toString();
return $token;
}
}

View file

@ -17,8 +17,10 @@ return Application::configure(basePath: dirname(__DIR__))
->append(LinkHeadersMiddleware::class)
->preventRequestForgery(
except: [
'auth', // This is the IndieAuth auth endpoint
'token', // This is the IndieAuth token endpoint
'auth', // This is the IndieAuth auth endpoint
'token', // This is the IndieAuth token endpoint
'revocation', // This is the IndieAuth revocation endpoint
'introspect', // This is the IndieAuth introspection endpoint
'api/post',
'api/media',
'micropub/places',

View file

@ -22,7 +22,6 @@
"laravel/horizon": "^5.0",
"laravel/scout": "^10.1",
"laravel/tinker": "^3.0",
"lcobucci/jwt": "^5.0",
"league/commonmark": "^2.0",
"league/flysystem-aws-s3-v3": "^3.0",
"mf2/mf2": "~0.3",

75
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "23983a4e6a8e79cb9636fe8f0e604eb7",
"content-hash": "a2842cf95580a08ad759a92d74fae3b4",
"packages": [
{
"name": "aws/aws-crt-php",
@ -2431,79 +2431,6 @@
},
"time": "2026-03-17T14:54:13+00:00"
},
{
"name": "lcobucci/jwt",
"version": "5.6.0",
"source": {
"type": "git",
"url": "https://github.com/lcobucci/jwt.git",
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e",
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"ext-sodium": "*",
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"psr/clock": "^1.0"
},
"require-dev": {
"infection/infection": "^0.29",
"lcobucci/clock": "^3.2",
"lcobucci/coding-standard": "^11.0",
"phpbench/phpbench": "^1.2",
"phpstan/extension-installer": "^1.2",
"phpstan/phpstan": "^1.10.7",
"phpstan/phpstan-deprecation-rules": "^1.1.3",
"phpstan/phpstan-phpunit": "^1.3.10",
"phpstan/phpstan-strict-rules": "^1.5.0",
"phpunit/phpunit": "^11.1"
},
"suggest": {
"lcobucci/clock": ">= 3.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Lcobucci\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Luís Cobucci",
"email": "lcobucci@gmail.com",
"role": "Developer"
}
],
"description": "A simple library to work with JSON Web Token and JSON Web Signature",
"keywords": [
"JWS",
"jwt"
],
"support": {
"issues": "https://github.com/lcobucci/jwt/issues",
"source": "https://github.com/lcobucci/jwt/tree/5.6.0"
},
"funding": [
{
"url": "https://github.com/lcobucci",
"type": "github"
},
{
"url": "https://www.patreon.com/lcobucci",
"type": "patreon"
}
],
"time": "2025-10-17T11:30:53+00:00"
},
{
"name": "league/commonmark",
"version": "2.8.3",

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('micropub_tokens', function (Blueprint $table) {
$table->id();
$table->string('token_hash', 64)->unique();
$table->string('client_id');
$table->string('me');
$table->string('scope');
$table->timestamp('revoked_at')->nullable();
$table->timestamps();
$table->index('client_id');
});
}
public function down(): void
{
Schema::dropIfExists('micropub_tokens');
}
};

View file

@ -0,0 +1,27 @@
@extends('master')
@section('title')List Tokens « Admin CP « @stop
@section('content')
<h1>Micropub Tokens</h1>
@if($tokens->isEmpty())
<p>No tokens have been issued.</p>
@else
<ul>
@foreach($tokens as $token)
<li>
{{ $token->client_id }} scope: {{ $token->scope }} issued {{ $token->created_at->diffForHumans() }}
@if($token->isRevoked)
revoked {{ $token->revoked_at->diffForHumans() }}
@else
<form action="/admin/tokens/{{ $token->id }}/revoke" method="post">
{{ csrf_field() }}
{{ method_field('PUT') }}
<button type="submit" name="revoke">Revoke</button>
</form>
@endif
</li>
@endforeach
</ul>
@endif
@stop

View file

@ -47,6 +47,11 @@
or <a href="/admin/syndication">edit</a> them.
</p>
<h2>Tokens</h2>
<p>
View and <a href="/admin/tokens">revoke</a> issued Micropub tokens.
</p>
<h2>Bio</h2>
<p>
Edit your <a href="/admin/bio">bio</a>.

View file

@ -10,6 +10,7 @@ use App\Http\Controllers\Admin\NotesController as AdminNotesController;
use App\Http\Controllers\Admin\PasskeysController;
use App\Http\Controllers\Admin\PlacesController as AdminPlacesController;
use App\Http\Controllers\Admin\SyndicationTargetsController;
use App\Http\Controllers\Admin\TokensController;
use App\Http\Controllers\ArticlesController;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\BookmarksController;
@ -147,6 +148,12 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () {
Route::delete('/{clientId}', [ClientsController::class, 'destroy']);
});
// Micropub Tokens
Route::prefix('tokens')->group(function () {
Route::get('/', [TokensController::class, 'index']);
Route::put('/{token}/revoke', [TokensController::class, 'revoke']);
});
// Bio
Route::prefix('bio')->group(function () {
Route::get('/', [BioController::class, 'show'])->name('admin.bio.show');
@ -205,6 +212,8 @@ Route::get('auth', [IndieAuthController::class, 'start'])->middleware(MyAuthMidd
Route::post('auth/confirm', [IndieAuthController::class, 'confirm'])->middleware(MyAuthMiddleware::class);
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);

View file

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Admin;
use App\Models\MicropubToken;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class TokensTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function index_requires_authentication(): void
{
$response = $this->get('/admin/tokens');
$response->assertRedirect();
}
#[Test]
public function index_lists_issued_tokens(): void
{
$user = User::factory()->make();
$token = MicropubToken::create([
'token_hash' => hash('sha256', 'a-token'),
'client_id' => 'https://quill.p3k.io',
'me' => 'https://jonnybarnes.uk',
'scope' => 'create update',
]);
$response = $this->actingAs($user)->get('/admin/tokens');
$response->assertOk();
$response->assertSeeText($token->client_id);
}
#[Test]
public function revoke_requires_authentication(): void
{
$token = MicropubToken::create([
'token_hash' => hash('sha256', 'a-token'),
'client_id' => 'https://quill.p3k.io',
'me' => 'https://jonnybarnes.uk',
'scope' => 'create',
]);
$response = $this->put("/admin/tokens/{$token->id}/revoke");
$response->assertRedirect();
$this->assertFalse($token->fresh()->isRevoked);
}
#[Test]
public function revoke_marks_the_token_as_revoked(): void
{
$user = User::factory()->make();
$token = MicropubToken::create([
'token_hash' => hash('sha256', 'a-token'),
'client_id' => 'https://quill.p3k.io',
'me' => 'https://jonnybarnes.uk',
'scope' => 'create',
]);
$this->actingAs($user)->put("/admin/tokens/{$token->id}/revoke");
$this->assertTrue($token->fresh()->isRevoked);
}
#[Test]
public function revoke_redirects_to_index(): void
{
$user = User::factory()->make();
$token = MicropubToken::create([
'token_hash' => hash('sha256', 'a-token'),
'client_id' => 'https://quill.p3k.io',
'me' => 'https://jonnybarnes.uk',
'scope' => 'create',
]);
$response = $this->actingAs($user)->put("/admin/tokens/{$token->id}/revoke");
$response->assertRedirect('/admin/tokens');
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class CsrfExemptionsTest extends TestCase
{
/**
* CSRF verification is short-circuited entirely while running the test
* suite (see PreventRequestForgery::runningUnitTests()), so a normal
* feature test hitting these routes would pass even if they were never
* added to bootstrap/app.php's except list. Assert against the actual
* configured exemptions instead.
*/
#[Test]
public function external_api_endpoints_are_exempt_from_csrf_verification(): void
{
$exemptions = $this->app->make(PreventRequestForgery::class)->getExcludedPaths();
foreach (['auth', 'token', 'revocation', 'introspect', 'api/post', 'api/media', 'micropub/places', 'webmention'] as $path) {
$this->assertContains($path, $exemptions);
}
}
}

View file

@ -19,7 +19,9 @@ class HeaderLinkTest extends TestCase
$this->assertSame('<'.config('app.url').'/.well-known/indieauth-server>; rel="indieauth-metadata"', $linkHeaders[0]);
$this->assertSame('<'.config('app.url').'/auth>; rel="authorization_endpoint"', $linkHeaders[1]);
$this->assertSame('<'.config('app.url').'/token>; rel="token_endpoint"', $linkHeaders[2]);
$this->assertSame('<'.config('app.url').'/api/post>; rel="micropub"', $linkHeaders[3]);
$this->assertSame('<'.config('app.url').'/webmention>; rel="webmention"', $linkHeaders[4]);
$this->assertSame('<'.config('app.url').'/revocation>; rel="revocation_endpoint"', $linkHeaders[3]);
$this->assertSame('<'.config('app.url').'/introspect>; rel="introspection_endpoint"', $linkHeaders[4]);
$this->assertSame('<'.config('app.url').'/api/post>; rel="micropub"', $linkHeaders[5]);
$this->assertSame('<'.config('app.url').'/webmention>; rel="webmention"', $linkHeaders[6]);
}
}

View file

@ -4,7 +4,9 @@ declare(strict_types=1);
namespace Tests\Feature;
use App\Models\MicropubToken;
use App\Models\User;
use App\Services\TokenService;
use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\UriResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -27,9 +29,10 @@ class IndieAuthTest extends TestCase
'issuer' => config('app.url'),
'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' => 'introspection_endpoint',
// 'introspection_endpoint_auth_methods_supported' => ['none'],
]);
}
@ -692,4 +695,132 @@ class IndieAuthTest extends TestCase
'me' => config('app.url'),
]);
}
#[Test]
public function it_should_revoke_a_known_token(): void
{
$token = resolve(TokenService::class)->getNewToken([
'me' => config('app.url'),
'client_id' => 'https://app.example.com',
'scope' => 'create',
]);
$response = $this->post('/revocation', ['token' => $token]);
$response->assertStatus(200);
$this->assertTrue(
MicropubToken::where('token_hash', hash('sha256', $token))->firstOrFail()->isRevoked
);
}
#[Test]
public function it_should_return200_for_an_unknown_token(): void
{
$response = $this->post('/revocation', ['token' => bin2hex(random_bytes(32))]);
$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]);
}
#[Test]
public function revocation_does_not_error_on_an_array_shaped_token_param(): void
{
$response = $this->post('/revocation', ['token' => ['a', 'b']]);
$response->assertStatus(200);
}
#[Test]
public function introspection_does_not_error_on_an_array_shaped_token_param(): void
{
$callerToken = resolve(TokenService::class)->getNewToken([
'me' => config('app.url'),
'client_id' => 'https://app.example.com',
'scope' => 'create',
]);
$response = $this->post(
'/introspect',
['token' => ['a', 'b']],
['HTTP_Authorization' => 'Bearer '.$callerToken]
);
$response->assertStatus(200);
$response->assertExactJson(['active' => false]);
}
}

View file

@ -4,18 +4,16 @@ declare(strict_types=1);
namespace Tests\Feature;
use App\Models\MicropubToken;
use App\Services\TokenService;
use DateTimeImmutable;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Key\InMemory;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class TokenServiceTest extends TestCase
{
/**
* Given the token is dependent on a random nonce, the time of creation and
* the APP_KEY, to test, we shall create a token, and then verify it.
* Given the token is dependent on a random value and stored only as a
* hash, to test, we shall create a token, and then verify it.
*/
#[Test]
public function tokenservice_creates_valid_tokens(): void
@ -41,24 +39,29 @@ class TokenServiceTest extends TestCase
}
#[Test]
public function tokens_with_different_signing_key_are_not_valid(): void
public function unknown_tokens_are_not_valid(): void
{
$response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer '.bin2hex(random_bytes(32))]);
$response->assertJson([
'response' => 'error',
'error' => 'invalid_token',
'error_description' => 'The provided token did not pass validation',
]);
}
#[Test]
public function revoked_tokens_are_not_valid(): void
{
$tokenService = new TokenService;
$data = [
'me' => 'https://example.org',
'client_id' => 'https://quill.p3k.io',
'scope' => 'post',
];
$token = $tokenService->getNewToken($data);
$config = resolve(Configuration::class);
$token = $config->builder()
->issuedAt(new DateTimeImmutable)
->withClaim('client_id', $data['client_id'])
->withClaim('me', $data['me'])
->withClaim('scope', $data['scope'])
->withClaim('nonce', bin2hex(random_bytes(8)))
->getToken($config->signer(), InMemory::plainText(random_bytes(32)))
->toString();
MicropubToken::where('token_hash', hash('sha256', $token))->firstOrFail()->revoke();
$response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer '.$token]);
@ -68,4 +71,18 @@ class TokenServiceTest extends TestCase
'error_description' => 'The provided token did not pass validation',
]);
}
/**
* Request input for a "string" field can be sent as an array
* (e.g. token[]=a&token[]=b). Casting that to string throws in this app
* (warnings are promoted to exceptions), so findActive() must guard
* against it rather than assume its caller already validated the type.
*/
#[Test]
public function find_active_treats_non_string_input_as_absent(): void
{
$this->assertNull(MicropubToken::findActive(['a', 'b']));
$this->assertNull(MicropubToken::findActive(null));
$this->assertNull(MicropubToken::findActive(123));
}
}

View file

@ -2,53 +2,39 @@
namespace Tests;
use DateTimeImmutable;
use Lcobucci\JWT\Configuration;
use App\Services\TokenService;
trait TestToken
{
public function getToken(): string
{
$config = $this->app->make(Configuration::class);
return $config->builder()
->issuedAt(new DateTimeImmutable)
->withClaim('client_id', 'https://quill.p3k.io')
->withClaim('me', 'http://jonnybarnes.localhost')
->withClaim('scope', ['create', 'update'])
->getToken($config->signer(), $config->signingKey())
->toString();
return $this->app->make(TokenService::class)->getNewToken([
'client_id' => 'https://quill.p3k.io',
'me' => 'http://jonnybarnes.localhost',
'scope' => 'create update',
]);
}
public function getTokenWithIncorrectScope(): string
{
$config = $this->app->make(Configuration::class);
return $config->builder()
->issuedAt(new DateTimeImmutable)
->withClaim('client_id', 'https://quill.p3k.io')
->withClaim('me', 'https://jonnybarnes.localhost')
->withClaim('scope', 'view')
->getToken($config->signer(), $config->signingKey())
->toString();
return $this->app->make(TokenService::class)->getNewToken([
'client_id' => 'https://quill.p3k.io',
'me' => 'https://jonnybarnes.localhost',
'scope' => 'view',
]);
}
public function getTokenWithNoScope()
public function getTokenWithNoScope(): string
{
$config = $this->app->make(Configuration::class);
return $config->builder()
->issuedAt(new DateTimeImmutable)
->withClaim('client_id', 'https://quill.p3k.io')
->withClaim('me', 'https://jonnybarnes.localhost')
->getToken($config->signer(), $config->signingKey())
->toString();
return $this->app->make(TokenService::class)->getNewToken([
'client_id' => 'https://quill.p3k.io',
'me' => 'https://jonnybarnes.localhost',
'scope' => '',
]);
}
public function getInvalidToken()
public function getInvalidToken(): string
{
$token = $this->getToken();
return substr($token, 0, -5);
return bin2hex(random_bytes(32));
}
}