diff --git a/app/Http/Controllers/Admin/TokensController.php b/app/Http/Controllers/Admin/TokensController.php deleted file mode 100644 index 1b5348f9..00000000 --- a/app/Http/Controllers/Admin/TokensController.php +++ /dev/null @@ -1,33 +0,0 @@ -get(); - - return view('admin.tokens.index', compact('tokens')); - } - - /** - * Revoke a Micropub token. - */ - public function revoke(MicropubToken $token): RedirectResponse - { - $token->revoke(); - - return redirect('/admin/tokens'); - } -} diff --git a/app/Http/Controllers/IndieAuthController.php b/app/Http/Controllers/IndieAuthController.php index a795bce8..eeb59770 100644 --- a/app/Http/Controllers/IndieAuthController.php +++ b/app/Http/Controllers/IndieAuthController.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Http\Controllers; -use App\Models\MicropubToken; use App\Services\TokenService; use GuzzleHttp\Psr7\Uri; use Illuminate\Http\JsonResponse; @@ -25,10 +24,9 @@ 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'], ]); } @@ -180,50 +178,6 @@ 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 diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index 2df5d432..c6008a9c 100644 --- a/app/Http/Controllers/MicropubController.php +++ b/app/Http/Controllers/MicropubController.php @@ -15,6 +15,7 @@ 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 { @@ -134,7 +135,7 @@ class MicropubController extends Controller } // the default response is just to return the token data - /** @var array $tokenData */ + /** @var Token $tokenData */ $tokenData = $request->input('token_data'); return response()->json([ diff --git a/app/Http/Controllers/MicropubMediaController.php b/app/Http/Controllers/MicropubMediaController.php index d9f8ea32..da7c7dc2 100644 --- a/app/Http/Controllers/MicropubMediaController.php +++ b/app/Http/Controllers/MicropubMediaController.php @@ -26,7 +26,9 @@ class MicropubMediaController extends Controller $tokenData = $request->input('token_data'); $scopes = $tokenData['scope']; - $scopes = explode(' ', $scopes); + if (is_string($scopes)) { + $scopes = explode(' ', $scopes); + } if (! in_array('create', $scopes, true)) { return (new MicropubResponses)->insufficientScopeResponse(); } @@ -82,7 +84,9 @@ class MicropubMediaController extends Controller $tokenData = $request->input('token_data'); $scopes = $tokenData['scope']; - $scopes = explode(' ', $scopes); + if (is_string($scopes)) { + $scopes = explode(' ', $scopes); + } if (! in_array('create', $scopes, true)) { return (new MicropubResponses)->insufficientScopeResponse(); } diff --git a/app/Http/Middleware/LinkHeadersMiddleware.php b/app/Http/Middleware/LinkHeadersMiddleware.php index e2810f87..b9e55139 100644 --- a/app/Http/Middleware/LinkHeadersMiddleware.php +++ b/app/Http/Middleware/LinkHeadersMiddleware.php @@ -17,8 +17,6 @@ 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); diff --git a/app/Http/Middleware/VerifyMicropubToken.php b/app/Http/Middleware/VerifyMicropubToken.php index 530995ae..33d2cb12 100644 --- a/app/Http/Middleware/VerifyMicropubToken.php +++ b/app/Http/Middleware/VerifyMicropubToken.php @@ -5,9 +5,13 @@ 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 @@ -35,15 +39,15 @@ class VerifyMicropubToken ], 401); } - $token = MicropubToken::findActive($rawToken); - - if (! $token) { + try { + $tokenData = $this->validateToken($rawToken); + } catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) { $micropubResponses = new MicropubResponses; return $micropubResponses->invalidTokenResponse(); } - if ($token->scope === '') { + if ($tokenData->claims()->has('scope') === false) { $micropubResponses = new MicropubResponses; return $micropubResponses->tokenHasNoScopeResponse(); @@ -52,10 +56,26 @@ class VerifyMicropubToken return $next($request->merge([ 'access_token' => $rawToken, 'token_data' => [ - 'me' => $token->me, - 'scope' => $token->scope, - 'client_id' => $token->client_id, + 'me' => $tokenData->claims()->get('me'), + 'scope' => $tokenData->claims()->get('scope'), + 'client_id' => $tokenData->claims()->get('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; + } } diff --git a/app/Models/MicropubToken.php b/app/Models/MicropubToken.php deleted file mode 100644 index c4df41bc..00000000 --- a/app/Models/MicropubToken.php +++ /dev/null @@ -1,50 +0,0 @@ - '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, - ); - } -} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 68367a97..224472d1 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -7,6 +7,10 @@ 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; @@ -49,6 +53,17 @@ 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( diff --git a/app/Services/Micropub/Handlers/CardHandler.php b/app/Services/Micropub/Handlers/CardHandler.php index 6b24f21b..02e3a066 100644 --- a/app/Services/Micropub/Handlers/CardHandler.php +++ b/app/Services/Micropub/Handlers/CardHandler.php @@ -24,7 +24,9 @@ class CardHandler implements MicropubHandlerInterface assert($data instanceof CardData); $scopes = $data->tokenData['scope']; - $scopes = explode(' ', $scopes); + if (is_string($scopes)) { + $scopes = explode(' ', $scopes); + } if (! in_array('create', $scopes, true)) { throw new InvalidTokenScopeException; diff --git a/app/Services/Micropub/Handlers/EntryHandler.php b/app/Services/Micropub/Handlers/EntryHandler.php index 48bbb550..ef9740f2 100644 --- a/app/Services/Micropub/Handlers/EntryHandler.php +++ b/app/Services/Micropub/Handlers/EntryHandler.php @@ -27,7 +27,9 @@ class EntryHandler implements MicropubHandlerInterface assert($data instanceof EntryData); $scopes = $data->tokenData['scope']; - $scopes = explode(' ', $scopes); + if (is_string($scopes)) { + $scopes = explode(' ', $scopes); + } if (! in_array('create', $scopes, true)) { throw new InvalidTokenScopeException; diff --git a/app/Services/Micropub/Handlers/UpdateHandler.php b/app/Services/Micropub/Handlers/UpdateHandler.php index 136a0840..49f86063 100644 --- a/app/Services/Micropub/Handlers/UpdateHandler.php +++ b/app/Services/Micropub/Handlers/UpdateHandler.php @@ -30,7 +30,9 @@ class UpdateHandler implements MicropubHandlerInterface assert($data instanceof UpdateData); $scopes = $data->tokenData['scope']; - $scopes = explode(' ', $scopes); + if (is_string($scopes)) { + $scopes = explode(' ', $scopes); + } if (! in_array('update', $scopes, true)) { throw new InvalidTokenScopeException; diff --git a/app/Services/TokenService.php b/app/Services/TokenService.php index 2941c28b..68a9293b 100644 --- a/app/Services/TokenService.php +++ b/app/Services/TokenService.php @@ -5,26 +5,28 @@ declare(strict_types=1); namespace App\Services; use App\Jobs\AddClientToDatabase; -use App\Models\MicropubToken; +use DateTimeImmutable; +use Lcobucci\JWT\Configuration; class TokenService { /** - * Generate a new bearer token. + * Generate a JWT token. */ public function getNewToken(array $data): string { - $token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); + $config = resolve(Configuration::class); - MicropubToken::create([ - 'token_hash' => hash('sha256', $token), - 'client_id' => $data['client_id'], - 'me' => $data['me'], - 'scope' => $data['scope'], - ]); + $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()); dispatch(new AddClientToDatabase($data['client_id'])); - return $token; + return $token->toString(); } } diff --git a/bootstrap/app.php b/bootstrap/app.php index e29a3598..9c73bdb9 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -17,10 +17,8 @@ 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 - 'revocation', // This is the IndieAuth revocation endpoint - 'introspect', // This is the IndieAuth introspection endpoint + 'auth', // This is the IndieAuth auth endpoint + 'token', // This is the IndieAuth token endpoint 'api/post', 'api/media', 'micropub/places', diff --git a/composer.json b/composer.json index 5871ee02..520d12e1 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "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", diff --git a/composer.lock b/composer.lock index 4c98c2cf..6af58a17 100644 --- a/composer.lock +++ b/composer.lock @@ -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": "a2842cf95580a08ad759a92d74fae3b4", + "content-hash": "23983a4e6a8e79cb9636fe8f0e604eb7", "packages": [ { "name": "aws/aws-crt-php", @@ -2431,6 +2431,79 @@ }, "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", diff --git a/database/migrations/2026_08_13_120924_create_micropub_tokens_table.php b/database/migrations/2026_08_13_120924_create_micropub_tokens_table.php deleted file mode 100644 index e336912f..00000000 --- a/database/migrations/2026_08_13_120924_create_micropub_tokens_table.php +++ /dev/null @@ -1,30 +0,0 @@ -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'); - } -}; diff --git a/resources/views/admin/tokens/index.blade.php b/resources/views/admin/tokens/index.blade.php deleted file mode 100644 index 8836ab07..00000000 --- a/resources/views/admin/tokens/index.blade.php +++ /dev/null @@ -1,27 +0,0 @@ -@extends('master') - -@section('title')List Tokens « Admin CP « @stop - -@section('content') -

Micropub Tokens

- @if($tokens->isEmpty()) -

No tokens have been issued.

- @else - - @endif -@stop diff --git a/resources/views/admin/welcome.blade.php b/resources/views/admin/welcome.blade.php index 663cfdc4..269ccdc5 100644 --- a/resources/views/admin/welcome.blade.php +++ b/resources/views/admin/welcome.blade.php @@ -47,11 +47,6 @@ or edit them.

-

Tokens

-

- View and revoke issued Micropub tokens. -

-

Bio

Edit your bio. diff --git a/routes/web.php b/routes/web.php index dd594480..953b51ac 100644 --- a/routes/web.php +++ b/routes/web.php @@ -10,7 +10,6 @@ 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; @@ -148,12 +147,6 @@ 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'); @@ -212,8 +205,6 @@ 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); diff --git a/tests/Feature/Admin/TokensTest.php b/tests/Feature/Admin/TokensTest.php deleted file mode 100644 index 0c415296..00000000 --- a/tests/Feature/Admin/TokensTest.php +++ /dev/null @@ -1,87 +0,0 @@ -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'); - } -} diff --git a/tests/Feature/CsrfExemptionsTest.php b/tests/Feature/CsrfExemptionsTest.php deleted file mode 100644 index c03a3c2c..00000000 --- a/tests/Feature/CsrfExemptionsTest.php +++ /dev/null @@ -1,29 +0,0 @@ -app->make(PreventRequestForgery::class)->getExcludedPaths(); - - foreach (['auth', 'token', 'revocation', 'introspect', 'api/post', 'api/media', 'micropub/places', 'webmention'] as $path) { - $this->assertContains($path, $exemptions); - } - } -} diff --git a/tests/Feature/HeaderLinkTest.php b/tests/Feature/HeaderLinkTest.php index 3983b02c..874731a5 100644 --- a/tests/Feature/HeaderLinkTest.php +++ b/tests/Feature/HeaderLinkTest.php @@ -19,9 +19,7 @@ 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').'/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]); + $this->assertSame('<'.config('app.url').'/api/post>; rel="micropub"', $linkHeaders[3]); + $this->assertSame('<'.config('app.url').'/webmention>; rel="webmention"', $linkHeaders[4]); } } diff --git a/tests/Feature/IndieAuthTest.php b/tests/Feature/IndieAuthTest.php index 08282228..b32f4420 100644 --- a/tests/Feature/IndieAuthTest.php +++ b/tests/Feature/IndieAuthTest.php @@ -4,9 +4,7 @@ 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; @@ -29,10 +27,9 @@ 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'], ]); } @@ -695,132 +692,4 @@ 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]); - } } diff --git a/tests/Feature/TokenServiceTest.php b/tests/Feature/TokenServiceTest.php index 91b9e81d..7fe9e854 100644 --- a/tests/Feature/TokenServiceTest.php +++ b/tests/Feature/TokenServiceTest.php @@ -4,16 +4,18 @@ 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 value and stored only as a - * hash, to test, we shall create a token, and then verify it. + * 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. */ #[Test] public function tokenservice_creates_valid_tokens(): void @@ -39,29 +41,24 @@ class TokenServiceTest extends TestCase } #[Test] - public function unknown_tokens_are_not_valid(): void + public function tokens_with_different_signing_key_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); - MicropubToken::where('token_hash', hash('sha256', $token))->firstOrFail()->revoke(); + $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(); $response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer '.$token]); @@ -71,18 +68,4 @@ 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)); - } } diff --git a/tests/TestToken.php b/tests/TestToken.php index 21e0b753..287e2757 100644 --- a/tests/TestToken.php +++ b/tests/TestToken.php @@ -2,39 +2,53 @@ namespace Tests; -use App\Services\TokenService; +use DateTimeImmutable; +use Lcobucci\JWT\Configuration; trait TestToken { public function getToken(): string { - return $this->app->make(TokenService::class)->getNewToken([ - 'client_id' => 'https://quill.p3k.io', - 'me' => 'http://jonnybarnes.localhost', - 'scope' => 'create update', - ]); + $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(); } public function getTokenWithIncorrectScope(): string { - return $this->app->make(TokenService::class)->getNewToken([ - 'client_id' => 'https://quill.p3k.io', - 'me' => 'https://jonnybarnes.localhost', - 'scope' => 'view', - ]); + $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(); } - public function getTokenWithNoScope(): string + public function getTokenWithNoScope() { - return $this->app->make(TokenService::class)->getNewToken([ - 'client_id' => 'https://quill.p3k.io', - 'me' => 'https://jonnybarnes.localhost', - 'scope' => '', - ]); + $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(); } - public function getInvalidToken(): string + public function getInvalidToken() { - return bin2hex(random_bytes(32)); + $token = $this->getToken(); + + return substr($token, 0, -5); } }