[MTM] Initial token re-work #117

Merged
jonny merged 5 commits from develop into main 2026-08-14 11:42:18 +02: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; namespace App\Http\Controllers;
use App\Models\MicropubToken;
use App\Services\TokenService; use App\Services\TokenService;
use GuzzleHttp\Psr7\Uri; use GuzzleHttp\Psr7\Uri;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@ -24,9 +25,10 @@ class IndieAuthController extends Controller
'issuer' => config('app.url'), 'issuer' => config('app.url'),
'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'),
'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'],
]); ]);
} }
@ -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 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

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

View file

@ -26,9 +26,7 @@ class MicropubMediaController extends Controller
$tokenData = $request->input('token_data'); $tokenData = $request->input('token_data');
$scopes = $tokenData['scope']; $scopes = $tokenData['scope'];
if (is_string($scopes)) { $scopes = explode(' ', $scopes);
$scopes = explode(' ', $scopes);
}
if (! in_array('create', $scopes, true)) { if (! in_array('create', $scopes, true)) {
return (new MicropubResponses)->insufficientScopeResponse(); return (new MicropubResponses)->insufficientScopeResponse();
} }
@ -84,9 +82,7 @@ class MicropubMediaController extends Controller
$tokenData = $request->input('token_data'); $tokenData = $request->input('token_data');
$scopes = $tokenData['scope']; $scopes = $tokenData['scope'];
if (is_string($scopes)) { $scopes = explode(' ', $scopes);
$scopes = explode(' ', $scopes);
}
if (! in_array('create', $scopes, true)) { if (! in_array('create', $scopes, true)) {
return (new MicropubResponses)->insufficientScopeResponse(); 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.metadata').'>; rel="indieauth-metadata"', false);
$response->header('Link', '<'.route('indieauth.start').'>; rel="authorization_endpoint"', 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.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('micropub-endpoint').'>; rel="micropub"', false);
$response->header('Link', '<'.route('webmention-endpoint').'>; rel="webmention"', false); $response->header('Link', '<'.route('webmention-endpoint').'>; rel="webmention"', false);

View file

@ -5,13 +5,9 @@ declare(strict_types=1);
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Http\Responses\MicropubResponses; use App\Http\Responses\MicropubResponses;
use App\Models\MicropubToken;
use Closure; use Closure;
use Illuminate\Http\Request; 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; use Symfony\Component\HttpFoundation\Response;
class VerifyMicropubToken class VerifyMicropubToken
@ -39,15 +35,15 @@ class VerifyMicropubToken
], 401); ], 401);
} }
try { $token = MicropubToken::findActive($rawToken);
$tokenData = $this->validateToken($rawToken);
} catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) { if (! $token) {
$micropubResponses = new MicropubResponses; $micropubResponses = new MicropubResponses;
return $micropubResponses->invalidTokenResponse(); return $micropubResponses->invalidTokenResponse();
} }
if ($tokenData->claims()->has('scope') === false) { if ($token->scope === '') {
$micropubResponses = new MicropubResponses; $micropubResponses = new MicropubResponses;
return $micropubResponses->tokenHasNoScopeResponse(); return $micropubResponses->tokenHasNoScopeResponse();
@ -56,26 +52,10 @@ class VerifyMicropubToken
return $next($request->merge([ return $next($request->merge([
'access_token' => $rawToken, 'access_token' => $rawToken,
'token_data' => [ 'token_data' => [
'me' => $tokenData->claims()->get('me'), 'me' => $token->me,
'scope' => $tokenData->claims()->get('scope'), 'scope' => $token->scope,
'client_id' => $tokenData->claims()->get('client_id'), '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\Collection;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider; 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\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; 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 // Configure HtmlSanitizer
$this->app->bind(HtmlSanitizer::class, function () { $this->app->bind(HtmlSanitizer::class, function () {
return new HtmlSanitizer( return new HtmlSanitizer(

View file

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

View file

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

View file

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

View file

@ -5,28 +5,26 @@ declare(strict_types=1);
namespace App\Services; namespace App\Services;
use App\Jobs\AddClientToDatabase; use App\Jobs\AddClientToDatabase;
use DateTimeImmutable; use App\Models\MicropubToken;
use Lcobucci\JWT\Configuration;
class TokenService class TokenService
{ {
/** /**
* Generate a JWT token. * Generate a new bearer token.
*/ */
public function getNewToken(array $data): string public function getNewToken(array $data): string
{ {
$config = resolve(Configuration::class); $token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$token = $config->builder() MicropubToken::create([
->issuedAt(new DateTimeImmutable) 'token_hash' => hash('sha256', $token),
->withClaim('client_id', $data['client_id']) 'client_id' => $data['client_id'],
->withClaim('me', $data['me']) 'me' => $data['me'],
->withClaim('scope', $data['scope']) 'scope' => $data['scope'],
->withClaim('nonce', bin2hex(random_bytes(8))) ]);
->getToken($config->signer(), $config->signingKey());
dispatch(new AddClientToDatabase($data['client_id'])); 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) ->append(LinkHeadersMiddleware::class)
->preventRequestForgery( ->preventRequestForgery(
except: [ except: [
'auth', // This is the IndieAuth auth endpoint 'auth', // This is the IndieAuth auth endpoint
'token', // This is the IndieAuth token 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/post',
'api/media', 'api/media',
'micropub/places', 'micropub/places',

View file

@ -22,7 +22,6 @@
"laravel/horizon": "^5.0", "laravel/horizon": "^5.0",
"laravel/scout": "^10.1", "laravel/scout": "^10.1",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"lcobucci/jwt": "^5.0",
"league/commonmark": "^2.0", "league/commonmark": "^2.0",
"league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-aws-s3-v3": "^3.0",
"mf2/mf2": "~0.3", "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", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "23983a4e6a8e79cb9636fe8f0e604eb7", "content-hash": "a2842cf95580a08ad759a92d74fae3b4",
"packages": [ "packages": [
{ {
"name": "aws/aws-crt-php", "name": "aws/aws-crt-php",
@ -2431,79 +2431,6 @@
}, },
"time": "2026-03-17T14:54:13+00:00" "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", "name": "league/commonmark",
"version": "2.8.3", "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. or <a href="/admin/syndication">edit</a> them.
</p> </p>
<h2>Tokens</h2>
<p>
View and <a href="/admin/tokens">revoke</a> issued Micropub tokens.
</p>
<h2>Bio</h2> <h2>Bio</h2>
<p> <p>
Edit your <a href="/admin/bio">bio</a>. 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\PasskeysController;
use App\Http\Controllers\Admin\PlacesController as AdminPlacesController; use App\Http\Controllers\Admin\PlacesController as AdminPlacesController;
use App\Http\Controllers\Admin\SyndicationTargetsController; use App\Http\Controllers\Admin\SyndicationTargetsController;
use App\Http\Controllers\Admin\TokensController;
use App\Http\Controllers\ArticlesController; use App\Http\Controllers\ArticlesController;
use App\Http\Controllers\AuthController; use App\Http\Controllers\AuthController;
use App\Http\Controllers\BookmarksController; use App\Http\Controllers\BookmarksController;
@ -147,6 +148,12 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () {
Route::delete('/{clientId}', [ClientsController::class, 'destroy']); 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 // Bio
Route::prefix('bio')->group(function () { Route::prefix('bio')->group(function () {
Route::get('/', [BioController::class, 'show'])->name('admin.bio.show'); 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/confirm', [IndieAuthController::class, 'confirm'])->middleware(MyAuthMiddleware::class);
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('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

@ -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').'/.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').'/auth>; rel="authorization_endpoint"', $linkHeaders[1]);
$this->assertSame('<'.config('app.url').'/token>; rel="token_endpoint"', $linkHeaders[2]); $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').'/revocation>; rel="revocation_endpoint"', $linkHeaders[3]);
$this->assertSame('<'.config('app.url').'/webmention>; rel="webmention"', $linkHeaders[4]); $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; namespace Tests\Feature;
use App\Models\MicropubToken;
use App\Models\User; use App\Models\User;
use App\Services\TokenService;
use GuzzleHttp\Psr7\Uri; use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\UriResolver; use GuzzleHttp\Psr7\UriResolver;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -27,9 +29,10 @@ class IndieAuthTest extends TestCase
'issuer' => config('app.url'), 'issuer' => config('app.url'),
'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'),
'introspection_endpoint' => route('indieauth.introspection'),
'introspection_endpoint_auth_methods_supported' => ['Bearer'],
'code_challenge_methods_supported' => ['S256'], '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'), '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; namespace Tests\Feature;
use App\Models\MicropubToken;
use App\Services\TokenService; use App\Services\TokenService;
use DateTimeImmutable;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Key\InMemory;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase; use Tests\TestCase;
class TokenServiceTest extends TestCase class TokenServiceTest extends TestCase
{ {
/** /**
* Given the token is dependent on a random nonce, the time of creation and * Given the token is dependent on a random value and stored only as a
* the APP_KEY, to test, we shall create a token, and then verify it. * hash, to test, we shall create a token, and then verify it.
*/ */
#[Test] #[Test]
public function tokenservice_creates_valid_tokens(): void public function tokenservice_creates_valid_tokens(): void
@ -41,24 +39,29 @@ class TokenServiceTest extends TestCase
} }
#[Test] #[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 = [ $data = [
'me' => 'https://example.org', 'me' => 'https://example.org',
'client_id' => 'https://quill.p3k.io', 'client_id' => 'https://quill.p3k.io',
'scope' => 'post', 'scope' => 'post',
]; ];
$token = $tokenService->getNewToken($data);
$config = resolve(Configuration::class); MicropubToken::where('token_hash', hash('sha256', $token))->firstOrFail()->revoke();
$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();
$response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer '.$token]); $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', '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; namespace Tests;
use DateTimeImmutable; use App\Services\TokenService;
use Lcobucci\JWT\Configuration;
trait TestToken trait TestToken
{ {
public function getToken(): string public function getToken(): string
{ {
$config = $this->app->make(Configuration::class); return $this->app->make(TokenService::class)->getNewToken([
'client_id' => 'https://quill.p3k.io',
return $config->builder() 'me' => 'http://jonnybarnes.localhost',
->issuedAt(new DateTimeImmutable) 'scope' => 'create update',
->withClaim('client_id', 'https://quill.p3k.io') ]);
->withClaim('me', 'http://jonnybarnes.localhost')
->withClaim('scope', ['create', 'update'])
->getToken($config->signer(), $config->signingKey())
->toString();
} }
public function getTokenWithIncorrectScope(): string public function getTokenWithIncorrectScope(): string
{ {
$config = $this->app->make(Configuration::class); return $this->app->make(TokenService::class)->getNewToken([
'client_id' => 'https://quill.p3k.io',
return $config->builder() 'me' => 'https://jonnybarnes.localhost',
->issuedAt(new DateTimeImmutable) 'scope' => 'view',
->withClaim('client_id', 'https://quill.p3k.io') ]);
->withClaim('me', 'https://jonnybarnes.localhost')
->withClaim('scope', 'view')
->getToken($config->signer(), $config->signingKey())
->toString();
} }
public function getTokenWithNoScope() public function getTokenWithNoScope(): string
{ {
$config = $this->app->make(Configuration::class); return $this->app->make(TokenService::class)->getNewToken([
'client_id' => 'https://quill.p3k.io',
return $config->builder() 'me' => 'https://jonnybarnes.localhost',
->issuedAt(new DateTimeImmutable) 'scope' => '',
->withClaim('client_id', 'https://quill.p3k.io') ]);
->withClaim('me', 'https://jonnybarnes.localhost')
->getToken($config->signer(), $config->signingKey())
->toString();
} }
public function getInvalidToken() public function getInvalidToken(): string
{ {
$token = $this->getToken(); return bin2hex(random_bytes(32));
return substr($token, 0, -5);
} }
} }