[MTM] Initial token re-work #117
25 changed files with 538 additions and 204 deletions
33
app/Http/Controllers/Admin/TokensController.php
Normal file
33
app/Http/Controllers/Admin/TokensController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -26,9 +26,7 @@ class MicropubMediaController extends Controller
|
|||
$tokenData = $request->input('token_data');
|
||||
|
||||
$scopes = $tokenData['scope'];
|
||||
if (is_string($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);
|
||||
}
|
||||
if (! in_array('create', $scopes, true)) {
|
||||
return (new MicropubResponses)->insufficientScopeResponse();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
50
app/Models/MicropubToken.php
Normal file
50
app/Models/MicropubToken.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -24,9 +24,7 @@ class CardHandler implements MicropubHandlerInterface
|
|||
assert($data instanceof CardData);
|
||||
|
||||
$scopes = $data->tokenData['scope'];
|
||||
if (is_string($scopes)) {
|
||||
$scopes = explode(' ', $scopes);
|
||||
}
|
||||
|
||||
if (! in_array('create', $scopes, true)) {
|
||||
throw new InvalidTokenScopeException;
|
||||
|
|
|
|||
|
|
@ -27,9 +27,7 @@ class EntryHandler implements MicropubHandlerInterface
|
|||
assert($data instanceof EntryData);
|
||||
|
||||
$scopes = $data->tokenData['scope'];
|
||||
if (is_string($scopes)) {
|
||||
$scopes = explode(' ', $scopes);
|
||||
}
|
||||
|
||||
if (! in_array('create', $scopes, true)) {
|
||||
throw new InvalidTokenScopeException;
|
||||
|
|
|
|||
|
|
@ -30,9 +30,7 @@ class UpdateHandler implements MicropubHandlerInterface
|
|||
assert($data instanceof UpdateData);
|
||||
|
||||
$scopes = $data->tokenData['scope'];
|
||||
if (is_string($scopes)) {
|
||||
$scopes = explode(' ', $scopes);
|
||||
}
|
||||
|
||||
if (! in_array('update', $scopes, true)) {
|
||||
throw new InvalidTokenScopeException;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
except: [
|
||||
'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',
|
||||
|
|
|
|||
|
|
@ -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
75
composer.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
27
resources/views/admin/tokens/index.blade.php
Normal file
27
resources/views/admin/tokens/index.blade.php
Normal 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
|
||||
|
|
@ -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>.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
87
tests/Feature/Admin/TokensTest.php
Normal file
87
tests/Feature/Admin/TokensTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
29
tests/Feature/CsrfExemptionsTest.php
Normal file
29
tests/Feature/CsrfExemptionsTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue