From 5b2bfd527005bc74156f2f02ccb4ed83d425b605 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 2 Jun 2024 10:16:16 +0100 Subject: [PATCH 1/4] Auth endpoint The IndieAuth endpoint should be added, currently adding the unt tests --- .../Controllers/Admin/PasskeysController.php | 108 +- app/Http/Controllers/IndieAuthController.php | 202 +++ app/Http/Middleware/MyAuthMiddleware.php | 2 + composer.json | 4 + composer.lock | 1146 ++++++++++------- docker-compose.yml | 2 +- public/assets/css/app.css | 1 + public/assets/css/app.css.br | Bin 71 -> 78 bytes public/assets/css/indieauth.css | 15 + public/assets/css/indieauth.css.br | Bin 0 -> 160 bytes public/assets/css/variables.css | 2 + public/assets/css/variables.css.br | Bin 400 -> 421 bytes public/assets/js/app.js.br | Bin 155 -> 213 bytes public/assets/js/auth.js.br | Bin 1348 -> 1353 bytes resources/views/indieauth/error.blade.php | 12 + resources/views/indieauth/start.blade.php | 39 + routes/web.php | 18 + tests/Feature/IndieAuthTest.php | 206 +++ 18 files changed, 1282 insertions(+), 475 deletions(-) create mode 100644 app/Http/Controllers/IndieAuthController.php create mode 100644 public/assets/css/indieauth.css create mode 100644 public/assets/css/indieauth.css.br create mode 100644 resources/views/indieauth/error.blade.php create mode 100644 resources/views/indieauth/start.blade.php create mode 100644 tests/Feature/IndieAuthTest.php diff --git a/app/Http/Controllers/Admin/PasskeysController.php b/app/Http/Controllers/Admin/PasskeysController.php index 5fdca622..49ca481b 100644 --- a/app/Http/Controllers/Admin/PasskeysController.php +++ b/app/Http/Controllers/Admin/PasskeysController.php @@ -19,7 +19,6 @@ use Illuminate\Support\Facades\Auth; use Illuminate\View\View; use ParagonIE\ConstantTime\Base64UrlSafe; use Throwable; -use Webauthn\AttestationStatement\AttestationObjectLoader; use Webauthn\AttestationStatement\AttestationStatementSupportManager; use Webauthn\AttestationStatement\NoneAttestationStatementSupport; use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler; @@ -28,9 +27,11 @@ use Webauthn\AuthenticatorAssertionResponseValidator; use Webauthn\AuthenticatorAttestationResponse; use Webauthn\AuthenticatorAttestationResponseValidator; use Webauthn\AuthenticatorSelectionCriteria; +use Webauthn\CeremonyStep\CeremonyStepManagerFactory; +use Webauthn\Denormalizer\WebauthnSerializerFactory; use Webauthn\Exception\WebauthnException; +use Webauthn\PublicKeyCredential; use Webauthn\PublicKeyCredentialCreationOptions; -use Webauthn\PublicKeyCredentialLoader; use Webauthn\PublicKeyCredentialParameters; use Webauthn\PublicKeyCredentialRequestOptions; use Webauthn\PublicKeyCredentialRpEntity; @@ -109,39 +110,57 @@ class PasskeysController extends Controller $user = auth()->user(); $publicKeyCredentialCreationOptionsData = session('create_options'); + // Unset session data to mitigate replay attacks + session()->forget('create_options'); if (empty($publicKeyCredentialCreationOptionsData)) { throw new WebAuthnException('No public key credential request options found'); } - $publicKeyCredentialCreationOptions = PublicKeyCredentialCreationOptions::createFromString($publicKeyCredentialCreationOptionsData); - // Unset session data to mitigate replay attacks - session()->forget('create_options'); + $attestationStatementSupportManager = new AttestationStatementSupportManager(); + $attestationStatementSupportManager->add(new NoneAttestationStatementSupport()); - $attestationSupportManager = AttestationStatementSupportManager::create(); - $attestationSupportManager->add(NoneAttestationStatementSupport::create()); - $attestationObjectLoader = AttestationObjectLoader::create($attestationSupportManager); - $publicKeyCredentialLoader = PublicKeyCredentialLoader::create($attestationObjectLoader); + $webauthnSerializer = (new WebauthnSerializerFactory( + $attestationStatementSupportManager + ))->create(); - $publicKeyCredential = $publicKeyCredentialLoader->load(json_encode($request->all(), JSON_THROW_ON_ERROR)); + $publicKeyCredential = $webauthnSerializer->deserialize( + json_encode($request->all(), JSON_THROW_ON_ERROR), + PublicKeyCredential::class, + 'json' + ); if (! $publicKeyCredential->response instanceof AuthenticatorAttestationResponse) { throw new WebAuthnException('Invalid response type'); } - $attestationStatementSupportManager = AttestationStatementSupportManager::create(); - $attestationStatementSupportManager->add(NoneAttestationStatementSupport::create()); + $algorithmManager = new Manager(); + $algorithmManager->add(new Ed25519()); + $algorithmManager->add(new ES256()); + $algorithmManager->add(new RS256()); - $authenticatorAttestationResponseValidator = AuthenticatorAttestationResponseValidator::create( - attestationStatementSupportManager: $attestationStatementSupportManager, - publicKeyCredentialSourceRepository: null, - tokenBindingHandler: null, - extensionOutputCheckerHandler: ExtensionOutputCheckerHandler::create(), + $ceremonyStepManagerFactory = new CeremonyStepManagerFactory(); + $ceremonyStepManagerFactory->setAlgorithmManager($algorithmManager); + $ceremonyStepManagerFactory->setAttestationStatementSupportManager( + $attestationStatementSupportManager + ); + $ceremonyStepManagerFactory->setExtensionOutputCheckerHandler( + ExtensionOutputCheckerHandler::create() ); - $securedRelyingPartyId = []; if (App::environment('local', 'development')) { $securedRelyingPartyId = [config('url.longurl')]; } + $ceremonyStepManagerFactory->setSecuredRelyingPartyId($securedRelyingPartyId); + + $authenticatorAttestationResponseValidator = AuthenticatorAttestationResponseValidator::create( + ceremonyStepManager: $ceremonyStepManagerFactory->creationCeremony() + ); + + $publicKeyCredentialCreationOptions = $webauthnSerializer->deserialize( + $publicKeyCredentialCreationOptionsData, + PublicKeyCredentialCreationOptions::class, + 'json' + ); $publicKeyCredentialSource = $authenticatorAttestationResponseValidator->check( authenticatorAttestationResponse: $publicKeyCredential->response, @@ -187,14 +206,18 @@ class PasskeysController extends Controller ], 400); } - $publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions::createFromString($requestOptions); + $attestationStatementSupportManager = new AttestationStatementSupportManager(); + $attestationStatementSupportManager->add(new NoneAttestationStatementSupport()); - $attestationSupportManager = AttestationStatementSupportManager::create(); - $attestationSupportManager->add(NoneAttestationStatementSupport::create()); - $attestationObjectLoader = AttestationObjectLoader::create($attestationSupportManager); - $publicKeyCredentialLoader = PublicKeyCredentialLoader::create($attestationObjectLoader); + $webauthnSerializer = (new WebauthnSerializerFactory( + $attestationStatementSupportManager + ))->create(); - $publicKeyCredential = $publicKeyCredentialLoader->load(json_encode($request->all(), JSON_THROW_ON_ERROR)); + $publicKeyCredential = $webauthnSerializer->deserialize( + json_encode($request->all(), JSON_THROW_ON_ERROR), + PublicKeyCredential::class, + 'json' + ); if (! $publicKeyCredential->response instanceof AuthenticatorAssertionResponse) { return response()->json([ @@ -211,28 +234,47 @@ class PasskeysController extends Controller ], 404); } - $credential = PublicKeyCredentialSource::createFromArray(json_decode($passkey->passkey, true, 512, JSON_THROW_ON_ERROR)); + $publicKeyCredentialSource = $webauthnSerializer->deserialize( + $passkey->passkey, + PublicKeyCredentialSource::class, + 'json' + ); - $algorithmManager = Manager::create(); + $algorithmManager = new Manager(); $algorithmManager->add(new Ed25519()); $algorithmManager->add(new ES256()); $algorithmManager->add(new RS256()); - $authenticatorAssertionResponseValidator = new AuthenticatorAssertionResponseValidator( - publicKeyCredentialSourceRepository: null, - tokenBindingHandler: null, - extensionOutputCheckerHandler: ExtensionOutputCheckerHandler::create(), - algorithmManager: $algorithmManager, - ); + $attestationStatementSupportManager = new AttestationStatementSupportManager(); + $attestationStatementSupportManager->add(new NoneAttestationStatementSupport()); + $ceremonyStepManagerFactory = new CeremonyStepManagerFactory(); + $ceremonyStepManagerFactory->setAlgorithmManager($algorithmManager); + $ceremonyStepManagerFactory->setAttestationStatementSupportManager( + $attestationStatementSupportManager + ); + $ceremonyStepManagerFactory->setExtensionOutputCheckerHandler( + ExtensionOutputCheckerHandler::create() + ); $securedRelyingPartyId = []; if (App::environment('local', 'development')) { $securedRelyingPartyId = [config('url.longurl')]; } + $ceremonyStepManagerFactory->setSecuredRelyingPartyId($securedRelyingPartyId); + + $authenticatorAssertionResponseValidator = AuthenticatorAssertionResponseValidator::create( + ceremonyStepManager: $ceremonyStepManagerFactory->requestCeremony() + ); + + $publicKeyCredentialRequestOptions = $webauthnSerializer->deserialize( + $requestOptions, + PublicKeyCredentialRequestOptions::class, + 'json' + ); try { $authenticatorAssertionResponseValidator->check( - credentialId: $credential, + credentialId: $publicKeyCredentialSource, authenticatorAssertionResponse: $publicKeyCredential->response, publicKeyCredentialRequestOptions: $publicKeyCredentialRequestOptions, request: config('url.longurl'), diff --git a/app/Http/Controllers/IndieAuthController.php b/app/Http/Controllers/IndieAuthController.php new file mode 100644 index 00000000..845107ce --- /dev/null +++ b/app/Http/Controllers/IndieAuthController.php @@ -0,0 +1,202 @@ +all(), [ + 'response_type' => 'required:string', + 'client_id' => 'required', + 'redirect_uri' => 'required', + 'state' => 'required', + 'code_challenge' => 'required:string', + 'code_challenge_method' => 'required:string', + ], [ + 'response_type' => 'response_type is required', + 'client_id.required' => 'client_id is required to display which app is asking for authentication', + 'redirect_uri.required' => 'redirect_uri is required so we can progress successful requests', + 'state.required' => 'state is required', + 'code_challenge.required' => 'code_challenge is required', + 'code_challenge_method.required' => 'code_challenge_method is required', + ]); + + if ($validator->fails()) { + return view('indieauth.error')->withErrors($validator); + } + + if ($request->get('response_type') !== 'code') { + return view('indieauth.error')->withErrors(['response_type' => 'only a response_type of "code" is supported']); + } + + if (mb_strtoupper($request->get('code_challenge_method')) !== 'S256') { + return view('indieauth.error')->withErrors(['code_challenge_method' => 'only a code_challenge_method of "S256" is supported']); + } + + if (! $this->isValidRedirectUri($request->get('client_id'), $request->get('redirect_uri'))) { + return view('indieauth.error')->withErrors(['redirect_uri' => 'redirect_uri is not valid for this client_id']); + } + + $scopes = $request->get('scopes', ''); + $scopes = explode(' ', $scopes); + + return view('indieauth.start', [ + 'me' => $request->get('me'), + 'client_id' => $request->get('client_id'), + 'redirect_uri' => $request->get('redirect_uri'), + 'state' => $request->get('state'), + 'scopes' => $scopes, + 'code_challenge' => $request->get('code_challenge'), + 'code_challenge_method' => $request->get('code_challenge_method'), + ]); + } + + /** + * Confirm an IndieAuth approval request. + * + * Generates an auth code and redirects the user back to the client app. + * + * @throws RandomException + */ + public function confirm(Request $request): JsonResponse + { + $authCode = bin2hex(random_bytes(16)); + + $cacheKey = hash('xxh3', $request->get('client_id')); + + $indieAuthRequestData = [ + 'code_challenge' => $request->get('code_challenge'), + 'code_challenge_method' => $request->get('code_challenge_method'), + 'client_id' => $request->get('client_id'), + 'auth_code' => $authCode, + ]; + + Cache::put($cacheKey, $indieAuthRequestData, now()->addMinutes(10)); + + $redirectUri = new Uri($request->get('redirect_uri')); + $redirectUri = Uri::withQueryValues($redirectUri, [ + 'code' => $authCode, + 'me' => $request->get('me'), + 'state' => $request->get('state'), + ]); + + // For now just dump URL scheme + return response()->json([ + 'redirect_uri' => $redirectUri, + ]); + } + + /** + * Process a POST request to the IndieAuth endpoint. + * + * This is the second step in the IndieAuth flow, where the client app sends the auth code to the IndieAuth endpoint. + * @throws SodiumException + */ + public function processCodeExchange(Request $request): JsonResponse + { + // First check all the data is present + $validator = Validator::make($request->all(), [ + 'grant_type' => 'required:string', + 'code' => 'required:string', + 'client_id' => 'required', + 'redirect_uri' => 'required', + 'code_verifier' => 'required', + ]); + + if ($validator->fails()) { + return response()->json($validator->errors(), 400); + } + + if ($request->get('grant_type') !== 'authorization_code') { + return response()->json(['error' => 'only a grant_type of "authorization_code" is supported'], 400); + } + + // Check cache for auth code + $cacheKey = hash('xxh3', $request->get('client_id')); + $indieAuthRequestData = Cache::pull($cacheKey); + + if ($indieAuthRequestData === null) { + return response()->json(['error' => 'code is invalid'], 404); + } + + if ($indieAuthRequestData['auth_code'] !== $request->get('code')) { + return response()->json(['error' => 'code is invalid'], 400); + } + + // Check code verifier + if (! hash_equals( + $indieAuthRequestData['code_challenge'], + sodium_bin2base64( + hash('sha256', $request->get('code_verifier'), true), + SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING + ) + )) { + return response()->json(['error' => 'code_verifier is invalid'], 400); + } + + return response()->json([ + 'me' => config('app.url'), + ]); + } + + protected function isValidRedirectUri(string $clientId, string $redirectUri): bool + { + // If client_id is not a valid URL, then it's not valid + $clientIdParsed = \Mf2\parseUriToComponents($clientId); + if (! isset($clientIdParsed['authority'])) { + ray($clientIdParsed); + + return false; + } + + // If redirect_uri is not a valid URL, then it's not valid + $redirectUriParsed = \Mf2\parseUriToComponents($redirectUri); + if (! isset($redirectUriParsed['authority'])) { + ray($redirectUriParsed); + + return false; + } + + // If client_id and redirect_uri are the same host, then it's valid + if ($clientIdParsed['authority'] === $redirectUriParsed['authority']) { + return true; + } + + // Otherwise we need to check the redirect_uri is in the client_id's redirect_uris + $guzzle = resolve(Client::class); + + try { + $clientInfo = $guzzle->get($clientId); + } catch (Exception $e) { + ray('Failed to fetch client info', $e->getMessage()); + + return false; + } + + $clientInfoParsed = \Mf2\parse($clientInfo->getBody()->getContents(), $clientId); + + $redirectUris = $clientInfoParsed['rels']['redirect_uri'] ?? []; + + return in_array($redirectUri, $redirectUris); + } +} diff --git a/app/Http/Middleware/MyAuthMiddleware.php b/app/Http/Middleware/MyAuthMiddleware.php index 26c8315f..d0a938bc 100644 --- a/app/Http/Middleware/MyAuthMiddleware.php +++ b/app/Http/Middleware/MyAuthMiddleware.php @@ -20,6 +20,8 @@ class MyAuthMiddleware { if (Auth::check() === false) { // they’re not logged in, so send them to login form + redirect()->setIntendedUrl($request->url()); + return redirect()->route('login'); } diff --git a/composer.json b/composer.json index 3d0f9aca..decfe606 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "ext-intl": "*", "ext-json": "*", "ext-pgsql": "*", + "ext-sodium": "*", "cviebrock/eloquent-sluggable": "^11.0", "guzzlehttp/guzzle": "^7.2", "indieauth/client": "^1.1", @@ -26,9 +27,12 @@ "league/commonmark": "^2.0", "league/flysystem-aws-s3-v3": "^3.0", "mf2/mf2": "~0.3", + "phpdocumentor/reflection-docblock": "^5.3", "spatie/commonmark-highlighter": "^3.0", "spatie/laravel-ignition": "^2.1", "symfony/html-sanitizer": "^7.0", + "symfony/property-access": "^7.0", + "symfony/serializer": "^7.0", "web-auth/webauthn-lib": "^4.7" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 893bc61f..d81c7837 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": "8d335e0fa1848b7448208fc90ead613e", + "content-hash": "c8d12b416f44f430d0c44692a7eb0a45", "packages": [ { "name": "aws/aws-crt-php", @@ -157,25 +157,25 @@ }, { "name": "brick/math", - "version": "0.11.0", + "version": "0.12.1", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "0ad82ce168c82ba30d1c01ec86116ab52f589478" + "reference": "f510c0a40911935b77b86859eb5223d58d660df1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/0ad82ce168c82ba30d1c01ec86116ab52f589478", - "reference": "0ad82ce168c82ba30d1c01ec86116ab52f589478", + "url": "https://api.github.com/repos/brick/math/zipball/f510c0a40911935b77b86859eb5223d58d660df1", + "reference": "f510c0a40911935b77b86859eb5223d58d660df1", "shasum": "" }, "require": { - "php": "^8.0" + "php": "^8.1" }, "require-dev": { "php-coveralls/php-coveralls": "^2.2", - "phpunit/phpunit": "^9.0", - "vimeo/psalm": "5.0.0" + "phpunit/phpunit": "^10.1", + "vimeo/psalm": "5.16.0" }, "type": "library", "autoload": { @@ -195,12 +195,17 @@ "arithmetic", "bigdecimal", "bignum", + "bignumber", "brick", - "math" + "decimal", + "integer", + "math", + "mathematics", + "rational" ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.11.0" + "source": "https://github.com/brick/math/tree/0.12.1" }, "funding": [ { @@ -208,7 +213,7 @@ "type": "github" } ], - "time": "2023-01-15T23:15:59+00:00" + "time": "2023-11-29T23:19:16+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -653,6 +658,53 @@ }, "time": "2022-10-27T11:44:00+00:00" }, + { + "name": "doctrine/deprecations", + "version": "1.1.3", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab", + "reference": "dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^9", + "phpstan/phpstan": "1.4.10 || 1.10.15", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "psalm/plugin-phpunit": "0.18.4", + "psr/log": "^1 || ^2 || ^3", + "vimeo/psalm": "4.30.0 || 5.12.0" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "lib/Doctrine/Deprecations" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.3" + }, + "time": "2024-01-30T19:34:25+00:00" + }, { "name": "doctrine/inflector", "version": "2.0.10", @@ -1947,16 +1999,16 @@ }, { "name": "laravel/framework", - "version": "v11.1.1", + "version": "v11.7.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "1437cea6d2b04cbc83743fbb208e1a01efccd9ec" + "reference": "e5ac72f513f635f208024aa76b8a04efc1b47f93" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/1437cea6d2b04cbc83743fbb208e1a01efccd9ec", - "reference": "1437cea6d2b04cbc83743fbb208e1a01efccd9ec", + "url": "https://api.github.com/repos/laravel/framework/zipball/e5ac72f513f635f208024aa76b8a04efc1b47f93", + "reference": "e5ac72f513f635f208024aa76b8a04efc1b47f93", "shasum": "" }, "require": { @@ -1975,7 +2027,7 @@ "fruitcake/php-cors": "^1.3", "guzzlehttp/guzzle": "^7.8", "guzzlehttp/uri-template": "^1.0", - "laravel/prompts": "^0.1.15", + "laravel/prompts": "^0.1.18", "laravel/serializable-closure": "^1.3", "league/commonmark": "^2.2.1", "league/flysystem": "^3.8.0", @@ -2059,7 +2111,7 @@ "league/flysystem-sftp-v3": "^3.0", "mockery/mockery": "^1.6", "nyholm/psr7": "^1.2", - "orchestra/testbench-core": "^9.0.6", + "orchestra/testbench-core": "^9.0.15", "pda/pheanstalk": "^5.0", "phpstan/phpstan": "^1.4.7", "phpunit/phpunit": "^10.5|^11.0", @@ -2148,7 +2200,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2024-03-28T15:07:18+00:00" + "time": "2024-05-07T13:41:51+00:00" }, { "name": "laravel/horizon", @@ -2231,16 +2283,16 @@ }, { "name": "laravel/prompts", - "version": "v0.1.17", + "version": "v0.1.21", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "8ee9f87f7f9eadcbe21e9e72cd4176b2f06cd5b5" + "reference": "23ea808e8a145653e0ab29e30d4385e49f40a920" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/8ee9f87f7f9eadcbe21e9e72cd4176b2f06cd5b5", - "reference": "8ee9f87f7f9eadcbe21e9e72cd4176b2f06cd5b5", + "url": "https://api.github.com/repos/laravel/prompts/zipball/23ea808e8a145653e0ab29e30d4385e49f40a920", + "reference": "23ea808e8a145653e0ab29e30d4385e49f40a920", "shasum": "" }, "require": { @@ -2280,11 +2332,12 @@ "license": [ "MIT" ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.1.17" + "source": "https://github.com/laravel/prompts/tree/v0.1.21" }, - "time": "2024-03-13T16:05:43+00:00" + "time": "2024-04-30T12:46:16+00:00" }, { "name": "laravel/sanctum", @@ -2881,16 +2934,16 @@ }, { "name": "league/flysystem", - "version": "3.26.0", + "version": "3.27.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "072735c56cc0da00e10716dd90d5a7f7b40b36be" + "reference": "4729745b1ab737908c7d055148c9a6b3e959832f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/072735c56cc0da00e10716dd90d5a7f7b40b36be", - "reference": "072735c56cc0da00e10716dd90d5a7f7b40b36be", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/4729745b1ab737908c7d055148c9a6b3e959832f", + "reference": "4729745b1ab737908c7d055148c9a6b3e959832f", "shasum": "" }, "require": { @@ -2955,7 +3008,7 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.26.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.27.0" }, "funding": [ { @@ -2967,7 +3020,7 @@ "type": "github" } ], - "time": "2024-03-25T11:49:53+00:00" + "time": "2024-04-07T19:17:50+00:00" }, { "name": "league/flysystem-aws-s3-v3", @@ -3454,16 +3507,16 @@ }, { "name": "monolog/monolog", - "version": "3.5.0", + "version": "3.6.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "c915e2634718dbc8a4a15c61b0e62e7a44e14448" + "reference": "4b18b21a5527a3d5ffdac2fd35d3ab25a9597654" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/c915e2634718dbc8a4a15c61b0e62e7a44e14448", - "reference": "c915e2634718dbc8a4a15c61b0e62e7a44e14448", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/4b18b21a5527a3d5ffdac2fd35d3ab25a9597654", + "reference": "4b18b21a5527a3d5ffdac2fd35d3ab25a9597654", "shasum": "" }, "require": { @@ -3486,7 +3539,7 @@ "phpstan/phpstan": "^1.9", "phpstan/phpstan-deprecation-rules": "^1.0", "phpstan/phpstan-strict-rules": "^1.4", - "phpunit/phpunit": "^10.1", + "phpunit/phpunit": "^10.5.17", "predis/predis": "^1.1 || ^2", "ruflin/elastica": "^7", "symfony/mailer": "^5.4 || ^6", @@ -3539,7 +3592,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.5.0" + "source": "https://github.com/Seldaek/monolog/tree/3.6.0" }, "funding": [ { @@ -3551,7 +3604,7 @@ "type": "tidelift" } ], - "time": "2023-10-27T15:32:31+00:00" + "time": "2024-04-12T21:02:21+00:00" }, { "name": "mtdowling/jmespath.php", @@ -3621,16 +3674,16 @@ }, { "name": "nesbot/carbon", - "version": "3.2.2", + "version": "3.3.1", "source": { "type": "git", "url": "https://github.com/briannesbitt/Carbon.git", - "reference": "2d69b6de67e2a3c0652d0c9dfcfda8b4563c4cee" + "reference": "8ff64b92c1b1ec84fcde9f8bb9ff2ca34cb8a77a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/2d69b6de67e2a3c0652d0c9dfcfda8b4563c4cee", - "reference": "2d69b6de67e2a3c0652d0c9dfcfda8b4563c4cee", + "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/8ff64b92c1b1ec84fcde9f8bb9ff2ca34cb8a77a", + "reference": "8ff64b92c1b1ec84fcde9f8bb9ff2ca34cb8a77a", "shasum": "" }, "require": { @@ -3723,7 +3776,7 @@ "type": "tidelift" } ], - "time": "2024-03-28T12:59:49+00:00" + "time": "2024-05-01T06:54:22+00:00" }, { "name": "nette/schema", @@ -4126,6 +4179,174 @@ }, "time": "2022-06-14T06:56:20+00:00" }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "5.3.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "622548b623e81ca6d78b721c5e029f4ce664f170" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/622548b623e81ca6d78b721c5e029f4ce664f170", + "reference": "622548b623e81ca6d78b721c5e029f4ce664f170", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^7.2 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^1.3", + "webmozart/assert": "^1.9.1" + }, + "require-dev": { + "mockery/mockery": "~1.3.2", + "psalm/phar": "^4.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "account@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.3.0" + }, + "time": "2021-10-19T17:43:47+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "153ae662783729388a584b4361f2545e4d841e3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/153ae662783729388a584b4361f2545e4d841e3c", + "reference": "153ae662783729388a584b4361f2545e4d841e3c", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.3 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^1.13" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpunit/phpunit": "^9.5", + "rector/rector": "^0.13.9", + "vimeo/psalm": "^4.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.8.2" + }, + "time": "2024-02-23T11:10:43+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.2", @@ -4201,6 +4422,53 @@ ], "time": "2023-11-12T21:59:55+00:00" }, + { + "name": "phpstan/phpdoc-parser", + "version": "1.26.0", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "231e3186624c03d7e7c890ec662b81e6b0405227" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/231e3186624c03d7e7c890ec662b81e6b0405227", + "reference": "231e3186624c03d7e7c890ec662b81e6b0405227", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^4.15", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^1.5", + "phpstan/phpstan-phpunit": "^1.1", + "phpstan/phpstan-strict-rules": "^1.0", + "phpunit/phpunit": "^9.5", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.26.0" + }, + "time": "2024-02-23T16:05:55+00:00" + }, { "name": "psr/clock", "version": "1.0.0", @@ -4827,20 +5095,20 @@ }, { "name": "ramsey/uuid", - "version": "4.7.5", + "version": "4.7.6", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "5f0df49ae5ad6efb7afa69e6bfab4e5b1e080d8e" + "reference": "91039bc1faa45ba123c4328958e620d382ec7088" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/5f0df49ae5ad6efb7afa69e6bfab4e5b1e080d8e", - "reference": "5f0df49ae5ad6efb7afa69e6bfab4e5b1e080d8e", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/91039bc1faa45ba123c4328958e620d382ec7088", + "reference": "91039bc1faa45ba123c4328958e620d382ec7088", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11", + "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12", "ext-json": "*", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" @@ -4903,7 +5171,7 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.7.5" + "source": "https://github.com/ramsey/uuid/tree/4.7.6" }, "funding": [ { @@ -4915,7 +5183,7 @@ "type": "tidelift" } ], - "time": "2023-11-08T05:53:05+00:00" + "time": "2024-04-27T21:32:50+00:00" }, { "name": "scrivo/highlight.php", @@ -5551,16 +5819,16 @@ }, { "name": "symfony/clock", - "version": "v7.0.5", + "version": "v7.0.7", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "8b9d08887353d627d5f6c3bf3373b398b49051c2" + "reference": "2008671acb4a30b01c453de193cf9c80549ebda6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/8b9d08887353d627d5f6c3bf3373b398b49051c2", - "reference": "8b9d08887353d627d5f6c3bf3373b398b49051c2", + "url": "https://api.github.com/repos/symfony/clock/zipball/2008671acb4a30b01c453de193cf9c80549ebda6", + "reference": "2008671acb4a30b01c453de193cf9c80549ebda6", "shasum": "" }, "require": { @@ -5605,7 +5873,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v7.0.5" + "source": "https://github.com/symfony/clock/tree/v7.0.7" }, "funding": [ { @@ -5621,20 +5889,20 @@ "type": "tidelift" } ], - "time": "2024-03-02T12:46:12+00:00" + "time": "2024-04-18T09:29:19+00:00" }, { "name": "symfony/console", - "version": "v7.0.4", + "version": "v7.0.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "6b099f3306f7c9c2d2786ed736d0026b2903205f" + "reference": "c981e0e9380ce9f146416bde3150c79197ce9986" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/6b099f3306f7c9c2d2786ed736d0026b2903205f", - "reference": "6b099f3306f7c9c2d2786ed736d0026b2903205f", + "url": "https://api.github.com/repos/symfony/console/zipball/c981e0e9380ce9f146416bde3150c79197ce9986", + "reference": "c981e0e9380ce9f146416bde3150c79197ce9986", "shasum": "" }, "require": { @@ -5698,7 +5966,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.0.4" + "source": "https://github.com/symfony/console/tree/v7.0.7" }, "funding": [ { @@ -5714,20 +5982,20 @@ "type": "tidelift" } ], - "time": "2024-02-22T20:27:20+00:00" + "time": "2024-04-18T09:29:19+00:00" }, { "name": "symfony/css-selector", - "version": "v7.0.3", + "version": "v7.0.7", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "ec60a4edf94e63b0556b6a0888548bb400a3a3be" + "reference": "b08a4ad89e84b29cec285b7b1f781a7ae51cf4bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/ec60a4edf94e63b0556b6a0888548bb400a3a3be", - "reference": "ec60a4edf94e63b0556b6a0888548bb400a3a3be", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/b08a4ad89e84b29cec285b7b1f781a7ae51cf4bc", + "reference": "b08a4ad89e84b29cec285b7b1f781a7ae51cf4bc", "shasum": "" }, "require": { @@ -5763,7 +6031,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.0.3" + "source": "https://github.com/symfony/css-selector/tree/v7.0.7" }, "funding": [ { @@ -5779,20 +6047,20 @@ "type": "tidelift" } ], - "time": "2024-01-23T15:02:46+00:00" + "time": "2024-04-18T09:29:19+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.4.0", + "version": "v3.5.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "7c3aff79d10325257a001fcf92d991f24fc967cf" + "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/7c3aff79d10325257a001fcf92d991f24fc967cf", - "reference": "7c3aff79d10325257a001fcf92d991f24fc967cf", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1", + "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1", "shasum": "" }, "require": { @@ -5801,7 +6069,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "3.4-dev" + "dev-main": "3.5-dev" }, "thanks": { "name": "symfony/contracts", @@ -5830,7 +6098,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.4.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.0" }, "funding": [ { @@ -5846,20 +6114,20 @@ "type": "tidelift" } ], - "time": "2023-05-23T14:45:45+00:00" + "time": "2024-04-18T09:32:20+00:00" }, { "name": "symfony/error-handler", - "version": "v7.0.4", + "version": "v7.0.7", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "677b24759decff69e65b1e9d1471d90f95ced880" + "reference": "cf97429887e40480c847bfeb6c3991e1e2c086ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/677b24759decff69e65b1e9d1471d90f95ced880", - "reference": "677b24759decff69e65b1e9d1471d90f95ced880", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/cf97429887e40480c847bfeb6c3991e1e2c086ab", + "reference": "cf97429887e40480c847bfeb6c3991e1e2c086ab", "shasum": "" }, "require": { @@ -5905,7 +6173,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.0.4" + "source": "https://github.com/symfony/error-handler/tree/v7.0.7" }, "funding": [ { @@ -5921,20 +6189,20 @@ "type": "tidelift" } ], - "time": "2024-02-22T20:27:20+00:00" + "time": "2024-04-18T09:29:19+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.0.3", + "version": "v7.0.7", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "834c28d533dd0636f910909d01b9ff45cc094b5e" + "reference": "db2a7fab994d67d92356bb39c367db115d9d30f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/834c28d533dd0636f910909d01b9ff45cc094b5e", - "reference": "834c28d533dd0636f910909d01b9ff45cc094b5e", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/db2a7fab994d67d92356bb39c367db115d9d30f9", + "reference": "db2a7fab994d67d92356bb39c367db115d9d30f9", "shasum": "" }, "require": { @@ -5985,7 +6253,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.0.3" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.0.7" }, "funding": [ { @@ -6001,20 +6269,20 @@ "type": "tidelift" } ], - "time": "2024-01-23T15:02:46+00:00" + "time": "2024-04-18T09:29:19+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.4.0", + "version": "v3.5.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "a76aed96a42d2b521153fb382d418e30d18b59df" + "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/a76aed96a42d2b521153fb382d418e30d18b59df", - "reference": "a76aed96a42d2b521153fb382d418e30d18b59df", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/8f93aec25d41b72493c6ddff14e916177c9efc50", + "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50", "shasum": "" }, "require": { @@ -6024,7 +6292,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "3.4-dev" + "dev-main": "3.5-dev" }, "thanks": { "name": "symfony/contracts", @@ -6061,7 +6329,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.4.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.0" }, "funding": [ { @@ -6077,20 +6345,20 @@ "type": "tidelift" } ], - "time": "2023-05-23T14:45:45+00:00" + "time": "2024-04-18T09:32:20+00:00" }, { "name": "symfony/finder", - "version": "v7.0.0", + "version": "v7.0.7", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "6e5688d69f7cfc4ed4a511e96007e06c2d34ce56" + "reference": "4d58f0f4fe95a30d7b538d71197135483560b97c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/6e5688d69f7cfc4ed4a511e96007e06c2d34ce56", - "reference": "6e5688d69f7cfc4ed4a511e96007e06c2d34ce56", + "url": "https://api.github.com/repos/symfony/finder/zipball/4d58f0f4fe95a30d7b538d71197135483560b97c", + "reference": "4d58f0f4fe95a30d7b538d71197135483560b97c", "shasum": "" }, "require": { @@ -6125,7 +6393,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.0.0" + "source": "https://github.com/symfony/finder/tree/v7.0.7" }, "funding": [ { @@ -6141,7 +6409,7 @@ "type": "tidelift" } ], - "time": "2023-10-31T17:59:56+00:00" + "time": "2024-04-28T11:44:19+00:00" }, { "name": "symfony/html-sanitizer", @@ -6214,16 +6482,16 @@ }, { "name": "symfony/http-foundation", - "version": "v7.0.4", + "version": "v7.0.7", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "439fdfdd344943254b1ef6278613e79040548045" + "reference": "0194e064b8bdc29381462f790bab04e1cac8fdc8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/439fdfdd344943254b1ef6278613e79040548045", - "reference": "439fdfdd344943254b1ef6278613e79040548045", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/0194e064b8bdc29381462f790bab04e1cac8fdc8", + "reference": "0194e064b8bdc29381462f790bab04e1cac8fdc8", "shasum": "" }, "require": { @@ -6271,7 +6539,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.0.4" + "source": "https://github.com/symfony/http-foundation/tree/v7.0.7" }, "funding": [ { @@ -6287,20 +6555,20 @@ "type": "tidelift" } ], - "time": "2024-02-08T19:22:56+00:00" + "time": "2024-04-18T09:29:19+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.0.5", + "version": "v7.0.7", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "37c24ca28f65e3121a68f3dd4daeb36fb1fa2a72" + "reference": "e07bb9bd86e7cd8ba2d3d9c618eec9d1bbe06d25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/37c24ca28f65e3121a68f3dd4daeb36fb1fa2a72", - "reference": "37c24ca28f65e3121a68f3dd4daeb36fb1fa2a72", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/e07bb9bd86e7cd8ba2d3d9c618eec9d1bbe06d25", + "reference": "e07bb9bd86e7cd8ba2d3d9c618eec9d1bbe06d25", "shasum": "" }, "require": { @@ -6354,6 +6622,7 @@ "symfony/translation-contracts": "^2.5|^3", "symfony/uid": "^6.4|^7.0", "symfony/validator": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0", "symfony/var-exporter": "^6.4|^7.0", "twig/twig": "^3.0.4" }, @@ -6383,7 +6652,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.0.5" + "source": "https://github.com/symfony/http-kernel/tree/v7.0.7" }, "funding": [ { @@ -6399,20 +6668,20 @@ "type": "tidelift" } ], - "time": "2024-03-04T21:05:24+00:00" + "time": "2024-04-29T12:20:25+00:00" }, { "name": "symfony/mailer", - "version": "v7.0.4", + "version": "v7.0.7", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "72e16d87bf50a3ce195b9470c06bb9d7b816ea85" + "reference": "4ff41a7c7998a88cfdc31b5841ef64d9246fc56a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/72e16d87bf50a3ce195b9470c06bb9d7b816ea85", - "reference": "72e16d87bf50a3ce195b9470c06bb9d7b816ea85", + "url": "https://api.github.com/repos/symfony/mailer/zipball/4ff41a7c7998a88cfdc31b5841ef64d9246fc56a", + "reference": "4ff41a7c7998a88cfdc31b5841ef64d9246fc56a", "shasum": "" }, "require": { @@ -6463,7 +6732,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.0.4" + "source": "https://github.com/symfony/mailer/tree/v7.0.7" }, "funding": [ { @@ -6479,20 +6748,20 @@ "type": "tidelift" } ], - "time": "2024-02-03T21:34:19+00:00" + "time": "2024-04-18T09:29:19+00:00" }, { "name": "symfony/mime", - "version": "v7.0.3", + "version": "v7.0.7", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "c1ffe24ba6fdc3e3f0f3fcb93519103b326a3716" + "reference": "3adbf110c306546f6f00337f421d2edca0e8d3c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/c1ffe24ba6fdc3e3f0f3fcb93519103b326a3716", - "reference": "c1ffe24ba6fdc3e3f0f3fcb93519103b326a3716", + "url": "https://api.github.com/repos/symfony/mime/zipball/3adbf110c306546f6f00337f421d2edca0e8d3c0", + "reference": "3adbf110c306546f6f00337f421d2edca0e8d3c0", "shasum": "" }, "require": { @@ -6512,6 +6781,7 @@ "league/html-to-markdown": "^5.0", "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", "symfony/dependency-injection": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", "symfony/property-access": "^6.4|^7.0", "symfony/property-info": "^6.4|^7.0", "symfony/serializer": "^6.4|^7.0" @@ -6546,7 +6816,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.0.3" + "source": "https://github.com/symfony/mime/tree/v7.0.7" }, "funding": [ { @@ -6562,7 +6832,7 @@ "type": "tidelift" } ], - "time": "2024-01-30T08:34:29+00:00" + "time": "2024-04-18T09:29:19+00:00" }, { "name": "symfony/polyfill-ctype", @@ -7277,16 +7547,16 @@ }, { "name": "symfony/process", - "version": "v7.0.4", + "version": "v7.0.7", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "0e7727191c3b71ebec6d529fa0e50a01ca5679e9" + "reference": "3839e56b94dd1dbd13235d27504e66baf23faba0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/0e7727191c3b71ebec6d529fa0e50a01ca5679e9", - "reference": "0e7727191c3b71ebec6d529fa0e50a01ca5679e9", + "url": "https://api.github.com/repos/symfony/process/zipball/3839e56b94dd1dbd13235d27504e66baf23faba0", + "reference": "3839e56b94dd1dbd13235d27504e66baf23faba0", "shasum": "" }, "require": { @@ -7318,7 +7588,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.0.4" + "source": "https://github.com/symfony/process/tree/v7.0.7" }, "funding": [ { @@ -7334,20 +7604,179 @@ "type": "tidelift" } ], - "time": "2024-02-22T20:27:20+00:00" + "time": "2024-04-18T09:29:19+00:00" }, { - "name": "symfony/routing", - "version": "v7.0.5", + "name": "symfony/property-access", + "version": "v7.0.4", "source": { "type": "git", - "url": "https://github.com/symfony/routing.git", - "reference": "ba6bf07d43289c6a4b4591ddb75bc3bc5f069c19" + "url": "https://github.com/symfony/property-access.git", + "reference": "44e3746d4de8d0961a44ee332c74dd0918266127" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/ba6bf07d43289c6a4b4591ddb75bc3bc5f069c19", - "reference": "ba6bf07d43289c6a4b4591ddb75bc3bc5f069c19", + "url": "https://api.github.com/repos/symfony/property-access/zipball/44e3746d4de8d0961a44ee332c74dd0918266127", + "reference": "44e3746d4de8d0961a44ee332c74dd0918266127", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/property-info": "^6.4|^7.0" + }, + "require-dev": { + "symfony/cache": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" + ], + "support": { + "source": "https://github.com/symfony/property-access/tree/v7.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-02-16T13:44:10+00:00" + }, + { + "name": "symfony/property-info", + "version": "v7.0.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "e160f92ea827243abf2dbf36b8460b1377194406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/e160f92ea827243abf2dbf36b8460b1377194406", + "reference": "e160f92ea827243abf2dbf36b8460b1377194406", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/string": "^6.4|^7.0" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/dependency-injection": "<6.4", + "symfony/serializer": "<6.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2", + "phpstan/phpdoc-parser": "^1.0", + "symfony/cache": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/tree/v7.0.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-01-23T15:02:46+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.0.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "9f82bf7766ccc9c22ab7aeb9bebb98351483fa5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/9f82bf7766ccc9c22ab7aeb9bebb98351483fa5b", + "reference": "9f82bf7766ccc9c22ab7aeb9bebb98351483fa5b", "shasum": "" }, "require": { @@ -7399,7 +7828,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.0.5" + "source": "https://github.com/symfony/routing/tree/v7.0.7" }, "funding": [ { @@ -7415,25 +7844,121 @@ "type": "tidelift" } ], - "time": "2024-02-27T12:34:35+00:00" + "time": "2024-04-18T09:29:19+00:00" }, { - "name": "symfony/service-contracts", - "version": "v3.4.1", + "name": "symfony/serializer", + "version": "v7.0.4", "source": { "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "fe07cbc8d837f60caf7018068e350cc5163681a0" + "url": "https://github.com/symfony/serializer.git", + "reference": "c71d61c6c37804e10981960e5f5ebc2c8f0a4fbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/fe07cbc8d837f60caf7018068e350cc5163681a0", - "reference": "fe07cbc8d837f60caf7018068e350cc5163681a0", + "url": "https://api.github.com/repos/symfony/serializer/zipball/c71d61c6c37804e10981960e5f5ebc2c8f0a4fbb", + "reference": "c71d61c6c37804e10981960e5f5ebc2c8f0a4fbb", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/dependency-injection": "<6.4", + "symfony/property-access": "<6.4", + "symfony/property-info": "<6.4", + "symfony/uid": "<6.4", + "symfony/validator": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^3.2|^4.0|^5.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/error-handler": "^6.4|^7.0", + "symfony/filesystem": "^6.4|^7.0", + "symfony/form": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/property-info": "^6.4|^7.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0", + "symfony/var-exporter": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v7.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-02-22T20:27:20+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.5.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/bd1d9e59a81d8fa4acdcea3f617c581f7475a80f", + "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f", "shasum": "" }, "require": { "php": ">=8.1", - "psr/container": "^1.1|^2.0" + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" }, "conflict": { "ext-psr": "<1.1|>=2" @@ -7441,7 +7966,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "3.4-dev" + "dev-main": "3.5-dev" }, "thanks": { "name": "symfony/contracts", @@ -7481,7 +8006,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.4.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.5.0" }, "funding": [ { @@ -7497,20 +8022,20 @@ "type": "tidelift" } ], - "time": "2023-12-26T14:02:43+00:00" + "time": "2024-04-18T09:32:20+00:00" }, { "name": "symfony/string", - "version": "v7.0.4", + "version": "v7.0.7", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "f5832521b998b0bec40bee688ad5de98d4cf111b" + "reference": "e405b5424dc2528e02e31ba26b83a79fd4eb8f63" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/f5832521b998b0bec40bee688ad5de98d4cf111b", - "reference": "f5832521b998b0bec40bee688ad5de98d4cf111b", + "url": "https://api.github.com/repos/symfony/string/zipball/e405b5424dc2528e02e31ba26b83a79fd4eb8f63", + "reference": "e405b5424dc2528e02e31ba26b83a79fd4eb8f63", "shasum": "" }, "require": { @@ -7567,7 +8092,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.0.4" + "source": "https://github.com/symfony/string/tree/v7.0.7" }, "funding": [ { @@ -7583,20 +8108,20 @@ "type": "tidelift" } ], - "time": "2024-02-01T13:17:36+00:00" + "time": "2024-04-18T09:29:19+00:00" }, { "name": "symfony/translation", - "version": "v7.0.4", + "version": "v7.0.7", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "5b75e872f7d135d7abb4613809fadc8d9f3d30a0" + "reference": "1515e03afaa93e6419aba5d5c9d209159317100b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/5b75e872f7d135d7abb4613809fadc8d9f3d30a0", - "reference": "5b75e872f7d135d7abb4613809fadc8d9f3d30a0", + "url": "https://api.github.com/repos/symfony/translation/zipball/1515e03afaa93e6419aba5d5c9d209159317100b", + "reference": "1515e03afaa93e6419aba5d5c9d209159317100b", "shasum": "" }, "require": { @@ -7661,7 +8186,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.0.4" + "source": "https://github.com/symfony/translation/tree/v7.0.7" }, "funding": [ { @@ -7677,20 +8202,20 @@ "type": "tidelift" } ], - "time": "2024-02-22T20:27:20+00:00" + "time": "2024-04-18T09:29:19+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.4.1", + "version": "v3.5.0", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "06450585bf65e978026bda220cdebca3f867fde7" + "reference": "b9d2189887bb6b2e0367a9fc7136c5239ab9b05a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/06450585bf65e978026bda220cdebca3f867fde7", - "reference": "06450585bf65e978026bda220cdebca3f867fde7", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/b9d2189887bb6b2e0367a9fc7136c5239ab9b05a", + "reference": "b9d2189887bb6b2e0367a9fc7136c5239ab9b05a", "shasum": "" }, "require": { @@ -7699,7 +8224,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "3.4-dev" + "dev-main": "3.5-dev" }, "thanks": { "name": "symfony/contracts", @@ -7739,7 +8264,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.4.1" + "source": "https://github.com/symfony/translation-contracts/tree/v3.5.0" }, "funding": [ { @@ -7755,20 +8280,20 @@ "type": "tidelift" } ], - "time": "2023-12-26T14:02:43+00:00" + "time": "2024-04-18T09:32:20+00:00" }, { "name": "symfony/uid", - "version": "v7.0.3", + "version": "v7.0.7", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "87cedaf3fabd7b733859d4d77aa4ca598259054b" + "reference": "4f3a5d181999e25918586c8369de09e7814e7be2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/87cedaf3fabd7b733859d4d77aa4ca598259054b", - "reference": "87cedaf3fabd7b733859d4d77aa4ca598259054b", + "url": "https://api.github.com/repos/symfony/uid/zipball/4f3a5d181999e25918586c8369de09e7814e7be2", + "reference": "4f3a5d181999e25918586c8369de09e7814e7be2", "shasum": "" }, "require": { @@ -7813,7 +8338,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.0.3" + "source": "https://github.com/symfony/uid/tree/v7.0.7" }, "funding": [ { @@ -7829,20 +8354,20 @@ "type": "tidelift" } ], - "time": "2024-01-23T15:02:46+00:00" + "time": "2024-04-18T09:29:19+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.0.4", + "version": "v7.0.7", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "e03ad7c1535e623edbb94c22cc42353e488c6670" + "reference": "d1627b66fd87c8b4d90cabe5671c29d575690924" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/e03ad7c1535e623edbb94c22cc42353e488c6670", - "reference": "e03ad7c1535e623edbb94c22cc42353e488c6670", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/d1627b66fd87c8b4d90cabe5671c29d575690924", + "reference": "d1627b66fd87c8b4d90cabe5671c29d575690924", "shasum": "" }, "require": { @@ -7896,7 +8421,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.0.4" + "source": "https://github.com/symfony/var-dumper/tree/v7.0.7" }, "funding": [ { @@ -7912,7 +8437,7 @@ "type": "tidelift" } ], - "time": "2024-02-15T11:33:06+00:00" + "time": "2024-04-18T09:29:19+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -9162,53 +9687,6 @@ }, "time": "2019-12-04T15:06:13+00:00" }, - { - "name": "doctrine/deprecations", - "version": "1.1.3", - "source": { - "type": "git", - "url": "https://github.com/doctrine/deprecations.git", - "reference": "dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab", - "reference": "dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab", - "shasum": "" - }, - "require": { - "php": "^7.1 || ^8.0" - }, - "require-dev": { - "doctrine/coding-standard": "^9", - "phpstan/phpstan": "1.4.10 || 1.10.15", - "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "psalm/plugin-phpunit": "0.18.4", - "psr/log": "^1 || ^2 || ^3", - "vimeo/psalm": "4.30.0 || 5.12.0" - }, - "suggest": { - "psr/log": "Allows logging deprecations via PSR-3 logger implementation" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Deprecations\\": "lib/Doctrine/Deprecations" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", - "homepage": "https://www.doctrine-project.org/", - "support": { - "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.3" - }, - "time": "2024-01-30T19:34:25+00:00" - }, { "name": "fakerphp/faker", "version": "v1.23.1", @@ -10612,221 +11090,6 @@ }, "time": "2023-10-20T12:21:20+00:00" }, - { - "name": "phpdocumentor/reflection-common", - "version": "2.2.0", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionCommon.git", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-2.x": "2.x-dev" - } - }, - "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jaap van Otterdijk", - "email": "opensource@ijaap.nl" - } - ], - "description": "Common reflection classes used by phpdocumentor to reflect the code structure", - "homepage": "http://www.phpdoc.org", - "keywords": [ - "FQSEN", - "phpDocumentor", - "phpdoc", - "reflection", - "static analysis" - ], - "support": { - "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", - "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" - }, - "time": "2020-06-27T09:03:43+00:00" - }, - { - "name": "phpdocumentor/reflection-docblock", - "version": "5.3.0", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "622548b623e81ca6d78b721c5e029f4ce664f170" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/622548b623e81ca6d78b721c5e029f4ce664f170", - "reference": "622548b623e81ca6d78b721c5e029f4ce664f170", - "shasum": "" - }, - "require": { - "ext-filter": "*", - "php": "^7.2 || ^8.0", - "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^1.3", - "webmozart/assert": "^1.9.1" - }, - "require-dev": { - "mockery/mockery": "~1.3.2", - "psalm/phar": "^4.8" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.x-dev" - } - }, - "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Mike van Riel", - "email": "me@mikevanriel.com" - }, - { - "name": "Jaap van Otterdijk", - "email": "account@ijaap.nl" - } - ], - "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", - "support": { - "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.3.0" - }, - "time": "2021-10-19T17:43:47+00:00" - }, - { - "name": "phpdocumentor/type-resolver", - "version": "1.8.2", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "153ae662783729388a584b4361f2545e4d841e3c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/153ae662783729388a584b4361f2545e4d841e3c", - "reference": "153ae662783729388a584b4361f2545e4d841e3c", - "shasum": "" - }, - "require": { - "doctrine/deprecations": "^1.0", - "php": "^7.3 || ^8.0", - "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.13" - }, - "require-dev": { - "ext-tokenizer": "*", - "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", - "phpunit/phpunit": "^9.5", - "rector/rector": "^0.13.9", - "vimeo/psalm": "^4.25" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-1.x": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Mike van Riel", - "email": "me@mikevanriel.com" - } - ], - "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", - "support": { - "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.8.2" - }, - "time": "2024-02-23T11:10:43+00:00" - }, - { - "name": "phpstan/phpdoc-parser", - "version": "1.26.0", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "231e3186624c03d7e7c890ec662b81e6b0405227" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/231e3186624c03d7e7c890ec662b81e6b0405227", - "reference": "231e3186624c03d7e7c890ec662b81e6b0405227", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "require-dev": { - "doctrine/annotations": "^2.0", - "nikic/php-parser": "^4.15", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^1.5", - "phpstan/phpstan-phpunit": "^1.1", - "phpstan/phpstan-strict-rules": "^1.0", - "phpunit/phpunit": "^9.5", - "symfony/process": "^5.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "PHPStan\\PhpDocParser\\": [ - "src/" - ] - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "PHPDoc parser with support for nullable, intersection and generic types", - "support": { - "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.26.0" - }, - "time": "2024-02-23T16:05:55+00:00" - }, { "name": "phpstan/phpstan", "version": "1.10.63", @@ -13346,7 +13609,8 @@ "ext-dom": "*", "ext-intl": "*", "ext-json": "*", - "ext-pgsql": "*" + "ext-pgsql": "*", + "ext-sodium": "*" }, "platform-dev": [], "plugin-api-version": "2.6.0" diff --git a/docker-compose.yml b/docker-compose.yml index 5fb2a542..3db9674f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,4 @@ # For more information: https://laravel.com/docs/sail -version: '3' services: laravel.test: build: @@ -18,6 +17,7 @@ services: LARAVEL_SAIL: 1 XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}' XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}' + IGNITION_LOCAL_SITES_PATH: '${PWD}' volumes: - '.:/var/www/html' networks: diff --git a/public/assets/css/app.css b/public/assets/css/app.css index 9e91b28a..12bddcc2 100644 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -5,3 +5,4 @@ @import url('code.css'); @import url('content.css'); @import url('notes.css'); +@import url('indieauth.css'); diff --git a/public/assets/css/app.css.br b/public/assets/css/app.css.br index e58c83d2eb47616bc3bb66daf2eafd24228c1ada..8a4bcff62686c4a77e48735042930d8075784af4 100644 GIT binary patch literal 78 zcmV-U0I~m}*aiS%oGf=}im_DK0n(GT_$gvH|3*=ulLPj@TU%(!50+7SvOU}s`2i26 k|Dmwa!1LF07Zx2p23qy>)YVCNQ>o!R=%SPn%g!};) d%=|!Modx)-$1njgFsh-KPEMkGrOA8{W+w~dB7gt@ diff --git a/public/assets/css/indieauth.css b/public/assets/css/indieauth.css new file mode 100644 index 00000000..0ea0a600 --- /dev/null +++ b/public/assets/css/indieauth.css @@ -0,0 +1,15 @@ +.indieauth { + .error { + color: var(--color-danger); + background-color: var(--color-danger-shadow); + border: 1px solid var(--color-danger); + border-radius: .5rem; + + display: flex; + padding-inline: 1rem; + padding-block: .5rem; + width: fit-content; + + margin-block-end: 1rem; + } +} diff --git a/public/assets/css/indieauth.css.br b/public/assets/css/indieauth.css.br new file mode 100644 index 0000000000000000000000000000000000000000..de2684d3968b163fc440a179076cec8abbb9fbfb GIT binary patch literal 160 zcmV;R0AK&1hzS7XJg{xWYZqb`4l5}dp3IEaAPO#hb#AE|HQbGgEx7@cU{P>(nVZ}m zaL6GYqm06~D0N%uwrY;snqUy5i-si9=X<=)ZBI;#8BaJW!D+fGt}|nmV+>&OXySS~ z6|Ccn@zLy$sz@+Z8(a)i1sSY4QWWvLtn2KN>wCwsZTg4KsfHz07_j3cNn9cw@f O#b=huc!9#Gt@i+^qDtWa literal 0 HcmV?d00001 diff --git a/public/assets/css/variables.css b/public/assets/css/variables.css index cd0b7448..b0e81a89 100644 --- a/public/assets/css/variables.css +++ b/public/assets/css/variables.css @@ -20,4 +20,6 @@ --color-link-visited: oklch(70.44% 0.21 304.41deg); --color-primary-shadow: oklch(19.56% 0.054 125.505deg / 40%); --rss-color-link: oklch(67.59% 0.189 42.04deg); + --color-danger: oklch(64.41% 0.281 23.29deg); + --color-danger-shadow: oklch(64.41% 0.281 23.29deg / 10%); } diff --git a/public/assets/css/variables.css.br b/public/assets/css/variables.css.br index b55500f341636f5a0dc18ddd8f86523a8ae53014..d0a36c5d0c2f848eac91ee9f3ff8feef6a3d9560 100644 GIT binary patch literal 421 zcmV;W0b2gC_#pt`C~#wLE+ssiHp1!k%fdO8Jy$~YaosGiPYOV8xl*;Pa>&qW>mSmW zf}9^ex4Jy>mD>Yd*Vs45|g(@ z&?J$XY^1!ihE5nFv}F}$GOoX34s2wk3dQ>3RAHM|`)V{c_zNAYKGy^8`mHBI?0gF~gw ziOI7d1;p`D0%6>avym=aj6)Km2|c`jD)O8Z?bPB^alvF)UGV?h&_ix$^~tA_UxQeO z-WRl(m4@1e^j|HsW|mK_$#QmXxTvvC>{F+&#&d1fZPT`%JHns)e2XwIe3V?}{F=zf z+vsD+tB&W;dbeG=soIDF&r}urkU|_KpE{0}6>B3W`j`R~!6XL&%*s3f diff --git a/public/assets/js/app.js.br b/public/assets/js/app.js.br index 203d1bf17edb736c55489c257d6b57a82b2cbabc..e9e8716c6f0c063267e15bd8611593c8c93bcb8f 100644 GIT binary patch literal 213 zcmV;`04o2X;1K{}l%|$ph0&DqDnTmDscc-+p-_NIF#g;MAYp8#&WS$ogII=dEzlNy zUZw-V={6xkyL$|$DfCzn^6x$czThJiYF}l{}?edtbL{Tp%mYS%)T8dPq PvOHI)K0_V1l}#W7Ad6@> literal 155 zcmV;M0A&B6@Cg9nEU?=-f+GiK&roNX74QK!X^#li_!Ihs z^Wg7e!wR=n;1D>z(Qhszo%!H|JGdGFgxPOG6cVn6(P-ESB1eQI_Rke4(hqGTvce;@lC4(b14 z7lld#73Y{NWG%#FCHEHqyj5IV@woDqp$rj_BBM_Ds1ARSPs{?Qdv~_i<>4p2#n+>d zm1~0V)2j@JT5l5d6_vMAYC@#?V+_FISM-(xG^rjShxixXe3vQ2q`|EmYw8Wwxp6D0 zUw;%o%bf6Z(crptMS52HoV_4Z$x(oePD@G>8fDxWbm#WdmSTQhZisl~;>$dW ze4kiOXdO45K!VIbAvd0gc>)W=Gonl@JUTFxOs9%Om*9wT^R0Y)A)-@KmIKOt3j0w0 zDWZ7@;iBaBfcRD_f8nx-j0&Y_n1=*_e*gFK(Wj43%VYnp6C`&ylI-Nun)^9%jg zn-tq}TcHpAL3zDQ0z%-;DDxrL^XM^Q@u#c8L7ksY=Cr;)33u+{C3+O@5;F zIU0~K)wjO@`D!WdTPs$R3!5nsk`$DTj%Q2)ND5S-8jnG zC?&M#Paly4e^pdfaflC?L2oU_2duUpzy{kbLeQ3#nazhe{F;lda4gYO-CeEn#>hwvcv|Z z*wDTgpCl$i3YWJuoz2&|jm%mn^Ho9^%SA$L8n|M((qH@sf zCOs903%8|#L8_O3QX*Z;OTZQy5RH!AE6&ovf3|kPEvPBHM!y@V?_k_T%hj-U}8%R=CxSqQB_ai`;(%Qf^Q*)=@6uL@51)XrPX uDoBCwD;$hS9I;LabwV5Yoj=x>(!PPZ{448+fSA<(?Dq;h4Oie$8ae`LvYylc delta 1331 zcmV-31PJKVaSy*XU1%LHm>qX47OQPOGDcP{5&KE8JLf8o#Jg#I6P zQ7F_~@eRvD(ri3dQhx!!Tg9_G9#`Iyx5557B1%AC4S$kFLh%#5J3Hp`z>~hbcSIp8 z*Ck=4R~Zhq)+FdFDsQFKg9v>;3}EgndP@(Aocd%A{gRz`g(5-(cPI7h zPx^v6;VxlMV>rZh-9qUFe{_&SKDu8xVYP$^8GiBLO1(|?=7BZ;uO)ZV|HcN4WG;BM z${Qu;jdDw|=YqYrUTODD+{JLlkG)+R|ES=yNd^qWgyjQwfzmD-M#dM$@&jfU$T0U0 zbpqZCB|cSo3-^W$st~Ol6yW?P4YJ<4sQ);cvlsTAUxwjcMdW(-e~8cFVTHV;;JBm| ztYhSA%mrqICEWRp!E@<~@T{~sdoiJkLjdud){`VGDtHN~&h0Ih5#BC0_&Xx;Wghvx zhLsaq$4w`Y05g%pjU-|n#iG~+PbL(e8yNDX3q_&_;7CyOt$ceSeA5CJ!{h-9`%vC7 z!O}jsC>cE=y0uEMe{3R~!zgO;fSsrh{9Zo#^zmtV=HESrnw2jh{&7w6;G9ckVMnU-|C@VX`{v#u)0+ z*XohEz`3^l@UtgxHtO0gSE1&sWRO@zZngw^$x!m?xIIw4e@-iH3!Hr{Cvn*u2A-o9 zgQ*+0769sQJ?$G5N05Bh*QRzWJZO+<4cQdTcQYRLdiRTr zxLdO=iuIB@pW2L?ZYAvs%uxf!CLe=@!r^_G?S=t5!B(HF@b@tMQElFc>Yk# zp#-sBf57cI=!w)bc|Xne#1hf3Uapv~nQqH;^=idz&1739tJf>WYvx-TUqGcuX|m}_ zM~hM8U1B4d zdsmUdmwxvz{|7W7@1KP>>|1`o!gm)9!A3GHjV>6u+j59B|I$kdNb~d7RxWJ7Y(BDB z0J5M`k_jwxFX76FWWTP7BWOgkiU`*}t}a14wTF)C2iWz!jL2+B{AmiD&=- diff --git a/resources/views/indieauth/error.blade.php b/resources/views/indieauth/error.blade.php new file mode 100644 index 00000000..6846c0bb --- /dev/null +++ b/resources/views/indieauth/error.blade.php @@ -0,0 +1,12 @@ +@extends('master') + +@section('title')IndieAuth « @stop + +@section('content') +
+

IndieAuth

+ @foreach ($errors->all() as $message) +
{{ $message }}
+ @endforeach +
+@stop diff --git a/resources/views/indieauth/start.blade.php b/resources/views/indieauth/start.blade.php new file mode 100644 index 00000000..b5d9a2fe --- /dev/null +++ b/resources/views/indieauth/start.blade.php @@ -0,0 +1,39 @@ +@extends('master') + +@section('title')IndieAuth « @stop + +@section('content') +
+ @csrf + + + + + + + + @if(!empty($scopes)) + @foreach($scopes as $scope) + + @endforeach + @endif + +

IndieAuth

+ @if(!empty($error)) +
{{ $error }}
+ @endif + +

You are attempting to log in with the client {{ $client_id }}

+

After approving the request you will be redirected to {{ $redirect_uri }}

+ @if(!empty($scopes)) +

The client is requesting the following scopes:

+
    + @foreach($scopes as $scope) +
  • {{ $scope }}
  • + @endforeach +
+ @endif + + +
+@stop diff --git a/routes/web.php b/routes/web.php index ad613d35..6cf7730f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -16,6 +16,7 @@ use App\Http\Controllers\BookmarksController; use App\Http\Controllers\ContactsController; use App\Http\Controllers\FeedsController; use App\Http\Controllers\FrontPageController; +use App\Http\Controllers\IndieAuthController; use App\Http\Controllers\LikesController; use App\Http\Controllers\MicropubController; use App\Http\Controllers\MicropubMediaController; @@ -190,6 +191,23 @@ Route::domain(config('url.longurl'))->group(function () { Route::get('/tagged/{tag}', [BookmarksController::class, 'tagged']); }); + // IndieAuth + Route::get('auth', [IndieAuthController::class, 'start'])->middleware(MyAuthMiddleware::class); + Route::post('auth/confirm', [IndieAuthController::class, 'confirm'])->middleware(MyAuthMiddleware::class); + Route::post('auth', [IndieAuthController::class, 'processCodeExchange']); + + Route::get('/test-auth-cache', function () { + $cacheKey = hash('xxh3', 'http://jonnybarnes.localhost'); + + dump(Cache::get($cacheKey)); + }); + + Route::get('/test-me', function () { + return response()->json([ + 'me' => config('app.url'), + ]); + }); + // Token Endpoint Route::post('api/token', [TokenEndpointController::class, 'create']); diff --git a/tests/Feature/IndieAuthTest.php b/tests/Feature/IndieAuthTest.php new file mode 100644 index 00000000..4dae46b5 --- /dev/null +++ b/tests/Feature/IndieAuthTest.php @@ -0,0 +1,206 @@ +make(); + $url = url()->query('/auth', [ + 'response_type' => 'code', + 'me' => 'https://example.com', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'https://app.example.com/callback', + 'state' => '123456', + 'scopes' => 'create update delete', + 'code_challenge' => '123456', + 'code_challenge_method' => 'S256', + ]); + + $response = $this->actingAs($user)->get($url); + + $response->assertStatus(200); + $response->assertViewIs('indieauth.start'); + } + + #[Test] + public function itShouldReturnErrorViewWhenResponeTypeIsWrong(): void + { + $user = User::factory()->make(); + $url = url()->query('/auth', [ + 'response_type' => 'invalid_value', + 'me' => 'https://example.com', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'https://app.example.com/callback', + 'state' => '123456', + 'scopes' => 'create update delete', + 'code_challenge' => '123456', + 'code_challenge_method' => 'S256', + ]); + + $response = $this->actingAs($user)->get($url); + + $response->assertStatus(200); + $response->assertViewIs('indieauth.error'); + $response->assertSee('only a response_type of "code" is supported'); + } + + #[Test] + public function itShouldReturnErrorViewWhenResponeTypeIsMissing(): void + { + $user = User::factory()->make(); + $url = url()->query('/auth', [ + 'me' => 'https://example.com', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'https://app.example.com/callback', + 'state' => '123456', + 'scopes' => 'create update delete', + 'code_challenge' => '123456', + 'code_challenge_method' => 'S256', + ]); + + $response = $this->actingAs($user)->get($url); + + $response->assertStatus(200); + $response->assertViewIs('indieauth.error'); + $response->assertSee('response_type is required'); + } + + #[Test] + public function itShouldReturnErrorViewWhenClientIdIsMissing(): void + { + $user = User::factory()->make(); + $url = url()->query('/auth', [ + 'response_type' => 'code', + 'me' => 'https://example.com', + 'redirect_uri' => 'https://app.example.com/callback', + 'state' => '123456', + 'scopes' => 'create update delete', + 'code_challenge' => '123456', + 'code_challenge_method' => 'S256', + ]); + + $response = $this->actingAs($user)->get($url); + + $response->assertStatus(200); + $response->assertViewIs('indieauth.error'); + $response->assertSee('client_id is required'); + } + + #[Test] + public function itShouldReturnErrorViewWhenRedirectUriIsMissing(): void + { + $user = User::factory()->make(); + $url = url()->query('/auth', [ + 'response_type' => 'code', + 'me' => 'https://example.com', + 'client_id' => 'https://app.example.com', + 'state' => '123456', + 'scopes' => 'create update delete', + 'code_challenge' => '123456', + 'code_challenge_method' => 'S256', + ]); + + $response = $this->actingAs($user)->get($url); + + $response->assertStatus(200); + $response->assertViewIs('indieauth.error'); + $response->assertSee('redirect_uri is required'); + } + + #[Test] + public function itShouldReturnErrorViewWhenStateIsMissing(): void + { + $user = User::factory()->make(); + $url = url()->query('/auth', [ + 'response_type' => 'code', + 'me' => 'https://example.com', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'https://app.example.com/callback', + 'scopes' => 'create update delete', + 'code_challenge' => '123456', + 'code_challenge_method' => 'S256', + ]); + + $response = $this->actingAs($user)->get($url); + + $response->assertStatus(200); + $response->assertViewIs('indieauth.error'); + $response->assertSee('state is required'); + } + + #[Test] + public function itShouldReturnErrorViewWhenCodeChallengeIsMissing(): void + { + $user = User::factory()->make(); + $url = url()->query('/auth', [ + 'response_type' => 'code', + 'me' => 'https://example.com', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'https://app.example.com/callback', + 'state' => '123456', + 'scopes' => 'create update delete', + 'code_challenge_method' => 'S256', + ]); + + $response = $this->actingAs($user)->get($url); + + $response->assertStatus(200); + $response->assertViewIs('indieauth.error'); + $response->assertSee('code_challenge is required'); + } + + #[Test] + public function itShouldReturnErrorViewWhenCodeChallengeMethodIsMissing(): void + { + $user = User::factory()->make(); + $url = url()->query('/auth', [ + 'response_type' => 'code', + 'me' => 'https://example.com', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'https://app.example.com/callback', + 'state' => '123456', + 'scopes' => 'create update delete', + 'code_challenge' => '123456', + ]); + + $response = $this->actingAs($user)->get($url); + + $response->assertStatus(200); + $response->assertViewIs('indieauth.error'); + $response->assertSee('code_challenge_method is required'); + } + + #[Test] + public function itShouldReturnErrorViewWhenCodeChallengeMethodIsUnsupportedValue(): void + { + $user = User::factory()->make(); + $url = url()->query('/auth', [ + 'response_type' => 'code', + 'me' => 'https://example.com', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'https://app.example.com/callback', + 'state' => '123456', + 'scopes' => 'create update delete', + 'code_challenge' => '123456', + 'code_challenge_method' => 'invalid_value', + ]); + + $response = $this->actingAs($user)->get($url); + + $response->assertStatus(200); + $response->assertViewIs('indieauth.error'); + $response->assertSee('only a code_challenge_method of "S256" is supported'); + } +} From 7f70f75d057e1e43842745b8d86f91787d815074 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 8 Jun 2024 10:56:15 +0100 Subject: [PATCH 2/4] IndieAuth endpoint can now return access tokens --- app/Http/Controllers/IndieAuthController.php | 225 ++++++-- .../Controllers/TokenEndpointController.php | 109 ---- bootstrap/app.php | 3 +- routes/web.php | 19 +- tests/Feature/IndieAuthTest.php | 498 ++++++++++++++++++ tests/Feature/TokenEndpointTest.php | 73 --- 6 files changed, 683 insertions(+), 244 deletions(-) delete mode 100644 app/Http/Controllers/TokenEndpointController.php delete mode 100644 tests/Feature/TokenEndpointTest.php diff --git a/app/Http/Controllers/IndieAuthController.php b/app/Http/Controllers/IndieAuthController.php index 845107ce..b3330ae5 100644 --- a/app/Http/Controllers/IndieAuthController.php +++ b/app/Http/Controllers/IndieAuthController.php @@ -4,10 +4,12 @@ declare(strict_types=1); namespace App\Http\Controllers; +use App\Services\TokenService; use Exception; use GuzzleHttp\Client; use GuzzleHttp\Psr7\Uri; use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Validator; @@ -17,6 +19,18 @@ use SodiumException; class IndieAuthController extends Controller { + public function indieAuthMetadataEndpoint(): JsonResponse + { + return response()->json([ + 'issuer' => config('app.url'), + 'authorization_endpoint' => route('indieauth.start'), + 'token_endpoint' => route('indieauth.token'), + 'code_challenge_methods_supported' => ['S256'], + //'introspection_endpoint' => route('indieauth.introspection'), + //'introspection_endpoint_auth_methods_supported' => ['none'], + ]); + } + /** * Process a GET request to the IndieAuth endpoint. * @@ -78,7 +92,7 @@ class IndieAuthController extends Controller * * @throws RandomException */ - public function confirm(Request $request): JsonResponse + public function confirm(Request $request): RedirectResponse { $authCode = bin2hex(random_bytes(16)); @@ -88,7 +102,9 @@ class IndieAuthController extends Controller 'code_challenge' => $request->get('code_challenge'), 'code_challenge_method' => $request->get('code_challenge_method'), 'client_id' => $request->get('client_id'), + 'redirect_uri' => $request->get('redirect_uri'), 'auth_code' => $authCode, + 'scopes' => $request->get('scopes', ''), ]; Cache::put($cacheKey, $indieAuthRequestData, now()->addMinutes(10)); @@ -96,62 +112,33 @@ class IndieAuthController extends Controller $redirectUri = new Uri($request->get('redirect_uri')); $redirectUri = Uri::withQueryValues($redirectUri, [ 'code' => $authCode, - 'me' => $request->get('me'), 'state' => $request->get('state'), + 'iss' => config('app.url'), ]); // For now just dump URL scheme - return response()->json([ - 'redirect_uri' => $redirectUri, - ]); + // return response()->json([ + // 'redirect_uri' => $redirectUri, + // ]); + + return redirect()->away($redirectUri); } /** - * Process a POST request to the IndieAuth endpoint. + * Process a POST request to the IndieAuth auth endpoint. + * + * This is one possible second step in the IndieAuth flow, where the client app sends the auth code to the IndieAuth + * endpoint. As it is to the auth endpoint we return profile information. A similar request can be made to the token + * endpoint to get an access token. * - * This is the second step in the IndieAuth flow, where the client app sends the auth code to the IndieAuth endpoint. * @throws SodiumException */ public function processCodeExchange(Request $request): JsonResponse { - // First check all the data is present - $validator = Validator::make($request->all(), [ - 'grant_type' => 'required:string', - 'code' => 'required:string', - 'client_id' => 'required', - 'redirect_uri' => 'required', - 'code_verifier' => 'required', - ]); + $invalidCodeResponse = $this->validateAuthorizationCode($request); - if ($validator->fails()) { - return response()->json($validator->errors(), 400); - } - - if ($request->get('grant_type') !== 'authorization_code') { - return response()->json(['error' => 'only a grant_type of "authorization_code" is supported'], 400); - } - - // Check cache for auth code - $cacheKey = hash('xxh3', $request->get('client_id')); - $indieAuthRequestData = Cache::pull($cacheKey); - - if ($indieAuthRequestData === null) { - return response()->json(['error' => 'code is invalid'], 404); - } - - if ($indieAuthRequestData['auth_code'] !== $request->get('code')) { - return response()->json(['error' => 'code is invalid'], 400); - } - - // Check code verifier - if (! hash_equals( - $indieAuthRequestData['code_challenge'], - sodium_bin2base64( - hash('sha256', $request->get('code_verifier'), true), - SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING - ) - )) { - return response()->json(['error' => 'code_verifier is invalid'], 400); + if ($invalidCodeResponse instanceof JsonResponse) { + return $invalidCodeResponse; } return response()->json([ @@ -159,6 +146,46 @@ class IndieAuthController extends Controller ]); } + /** + * Process a POST request to the IndieAuth token endpoint. + * + * This is another possible second step in the IndieAuth flow, where the client app sends the auth code to the + * IndieAuth token endpoint. As it is to the token endpoint we return an access token. + * + * @throws SodiumException + */ + public function processTokenRequest(Request $request): JsonResponse + { + $indieAuthData = $this->validateAuthorizationCode($request); + + if ($indieAuthData instanceof JsonResponse) { + return $indieAuthData; + } + + if ($indieAuthData['scopes'] === '') { + return response()->json(['errors' => [ + 'scope' => [ + 'The scope property must be non-empty for an access token to be issued.', + ], + ]], 400); + } + + $tokenData = [ + 'me' => config('app.url'), + 'client_id' => $request->get('client_id'), + 'scope' => $indieAuthData['scopes'], + ]; + $tokenService = resolve(TokenService::class); + $token = $tokenService->getNewToken($tokenData); + + return response()->json([ + 'access_token' => $token, + 'token_type' => 'Bearer', + 'scope' => $indieAuthData['scopes'], + 'me' => config('app.url'), + ]); + } + protected function isValidRedirectUri(string $clientId, string $redirectUri): bool { // If client_id is not a valid URL, then it's not valid @@ -197,6 +224,114 @@ class IndieAuthController extends Controller $redirectUris = $clientInfoParsed['rels']['redirect_uri'] ?? []; - return in_array($redirectUri, $redirectUris); + return in_array($redirectUri, $redirectUris, true); + } + + protected function validateAuthorizationCode(Request $request): JsonResponse|array + { + // First check all the data is present + $validator = Validator::make($request->all(), [ + 'grant_type' => 'required:string', + 'code' => 'required:string', + 'client_id' => 'required', + 'redirect_uri' => 'required', + 'code_verifier' => 'required', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 400); + } + + if ($request->get('grant_type') !== 'authorization_code') { + return response()->json(['errors' => [ + 'grant_type' => [ + 'Only a grant type of "authorization_code" is supported.', + ], + ]], 400); + } + + // Check cache for auth code + $cacheKey = hash('xxh3', $request->get('client_id')); + $indieAuthRequestData = Cache::pull($cacheKey); + + if ($indieAuthRequestData === null) { + return response()->json(['errors' => [ + 'code' => [ + 'The code is invalid.', + ], + ]], 404); + } + + // Check the IndieAuth code + if (! array_key_exists('auth_code', $indieAuthRequestData)) { + return response()->json(['errors' => [ + 'code' => [ + 'The code is invalid.', + ], + ]], 400); + } + if ($indieAuthRequestData['auth_code'] !== $request->get('code')) { + return response()->json(['errors' => [ + 'code' => [ + 'The code is invalid.', + ], + ]], 400); + } + + // Check code verifier + if (! array_key_exists('code_challenge', $indieAuthRequestData)) { + return response()->json(['errors' => [ + 'code_verifier' => [ + 'The code verifier is invalid.', + ], + ]], 400); + } + if (! hash_equals( + $indieAuthRequestData['code_challenge'], + sodium_bin2base64( + hash('sha256', $request->get('code_verifier'), true), + SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING + ) + )) { + return response()->json(['errors' => [ + 'code_verifier' => [ + 'The code verifier is invalid.', + ], + ]], 400); + } + + // Check redirect_uri + if (! array_key_exists('redirect_uri', $indieAuthRequestData)) { + return response()->json(['errors' => [ + 'redirect_uri' => [ + 'The redirect uri is invalid.', + ], + ]], 400); + } + if ($indieAuthRequestData['redirect_uri'] !== $request->get('redirect_uri')) { + return response()->json(['errors' => [ + 'redirect_uri' => [ + 'The redirect uri is invalid.', + ], + ]], 400); + } + + // Check client_id + if (! array_key_exists('client_id', $indieAuthRequestData)) { + return response()->json(['errors' => [ + 'client_id' => [ + 'The client id is invalid.', + ], + ]], 400); + } + if ($indieAuthRequestData['client_id'] !== $request->get('client_id')) { + return response()->json(['errors' => [ + 'client_id' => [ + 'The client id is invalid.', + ], + ]], 400); + } + + return $indieAuthRequestData; } } diff --git a/app/Http/Controllers/TokenEndpointController.php b/app/Http/Controllers/TokenEndpointController.php deleted file mode 100644 index 3be0af66..00000000 --- a/app/Http/Controllers/TokenEndpointController.php +++ /dev/null @@ -1,109 +0,0 @@ -client = $client; - $this->guzzle = $guzzle; - $this->tokenService = $tokenService; - } - - /** - * If the user has auth’d via the IndieAuth protocol, issue a valid token. - */ - public function create(Request $request): JsonResponse - { - $auth = $this->verifyIndieAuthCode( - config('url.authorization_endpoint'), - $request->input('code'), - $request->input('redirect_uri'), - $request->input('client_id'), - ); - - if ($auth === null || ! array_key_exists('me', $auth)) { - return response()->json([ - 'error' => 'There was an error verifying the IndieAuth code', - ], 401); - } - - $scope = $auth['scope'] ?? ''; - $tokenData = [ - 'me' => config('app.url'), - 'client_id' => $request->input('client_id'), - 'scope' => $scope, - ]; - $token = $this->tokenService->getNewToken($tokenData); - $content = [ - 'me' => config('app.url'), - 'scope' => $scope, - 'access_token' => $token, - ]; - - return response()->json($content); - } - - protected function verifyIndieAuthCode( - string $authorizationEndpoint, - string $code, - string $redirectUri, - string $clientId - ): ?array { - try { - $response = $this->guzzle->request('POST', $authorizationEndpoint, [ - 'headers' => [ - 'Accept' => 'application/json', - ], - 'form_params' => [ - 'code' => $code, - 'me' => config('app.url'), - 'redirect_uri' => $redirectUri, - 'client_id' => $clientId, - ], - ]); - } catch (BadResponseException) { - return null; - } - - try { - $authData = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); - } catch (JsonException) { - return null; - } - - return $authData; - } -} diff --git a/bootstrap/app.php b/bootstrap/app.php index 32838ed3..3e55ca98 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,7 +12,8 @@ return Application::configure(basePath: dirname(__DIR__)) ) ->withMiddleware(function (Middleware $middleware) { $middleware->validateCsrfTokens(except: [ - 'api/token', + 'auth', // This is the IndieAuth auth endpoint + 'token', // This is the IndieAuth token endpoint 'api/post', 'api/media', 'micropub/places', diff --git a/routes/web.php b/routes/web.php index 6cf7730f..aed8e64d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -192,24 +192,11 @@ Route::domain(config('url.longurl'))->group(function () { }); // IndieAuth - Route::get('auth', [IndieAuthController::class, 'start'])->middleware(MyAuthMiddleware::class); + Route::get('.well-known/indieauth-server', [IndieAuthController::class, 'indieAuthMetadataEndpoint']); + Route::get('auth', [IndieAuthController::class, 'start'])->middleware(MyAuthMiddleware::class)->name('indieauth.start'); Route::post('auth/confirm', [IndieAuthController::class, 'confirm'])->middleware(MyAuthMiddleware::class); Route::post('auth', [IndieAuthController::class, 'processCodeExchange']); - - Route::get('/test-auth-cache', function () { - $cacheKey = hash('xxh3', 'http://jonnybarnes.localhost'); - - dump(Cache::get($cacheKey)); - }); - - Route::get('/test-me', function () { - return response()->json([ - 'me' => config('app.url'), - ]); - }); - - // Token Endpoint - Route::post('api/token', [TokenEndpointController::class, 'create']); + Route::post('token', [IndieAuthController::class, 'processTokenRequest'])->name('indieauth.token'); // Micropub Endpoints Route::get('api/post', [MicropubController::class, 'get'])->middleware(VerifyMicropubToken::class); diff --git a/tests/Feature/IndieAuthTest.php b/tests/Feature/IndieAuthTest.php index 4dae46b5..39d90cd3 100644 --- a/tests/Feature/IndieAuthTest.php +++ b/tests/Feature/IndieAuthTest.php @@ -5,7 +5,14 @@ declare(strict_types=1); namespace Tests\Feature; use App\Models\User; +use GuzzleHttp\Client; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Psr7\Response; +use GuzzleHttp\Psr7\Uri; +use GuzzleHttp\Psr7\UriResolver; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Cache; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -13,6 +20,44 @@ class IndieAuthTest extends TestCase { use RefreshDatabase; + #[Test] + public function itShouldReturnIndieAuthMetadata(): void + { + $response = $this->get('/.well-known/indieauth-server'); + + $response->assertStatus(200); + $response->assertJson([ + 'issuer' => config('app.url'), + 'authorization_endpoint' => route('indieauth.start'), + 'token_endpoint' => route('indieauth.token'), + 'code_challenge_methods_supported' => ['S256'], + //'introspection_endpoint' => 'introspection_endpoint', + //'introspection_endpoint_auth_methods_supported' => ['none'], + ]); + } + + #[Test] + public function itShouldRequireAdminLoginToShowAuthoriseForm(): void + { + $response = $this->get('/auth', [ + 'response_type' => 'code', + 'me' => 'https://example.com', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'https://app.example.com/callback', + 'state' => '123456', + 'scopes' => 'create update delete', + 'code_challenge' => '123456', + 'code_challenge_method' => 'S256', + ]); + + $response->assertStatus(302); + $response->assertRedirect('/login'); + } + + /** + * The test passes here because the client_id and redirect_uri are on the + * same domain, later test will check the flow when they are different. + */ #[Test] public function itShouldReturnApprovalViewWhenTheRequestIsValid(): void { @@ -203,4 +248,457 @@ class IndieAuthTest extends TestCase $response->assertViewIs('indieauth.error'); $response->assertSee('only a code_challenge_method of "S256" is supported'); } + + #[Test] + public function itShouldCheckClientIdForValidRedirect(): void + { + // Mock Guzzle request for client_id + $appPageHtml = <<<'HTML' + + + + + Example App + + + + + + + HTML; + + $mockHandler = new MockHandler([ + new Response(200, [], $appPageHtml), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $mockGuzzleClient = new Client(['handler' => $handlerStack]); + $this->app->instance(Client::class, $mockGuzzleClient); + + $user = User::factory()->make(); + $url = url()->query('/auth', [ + 'response_type' => 'code', + 'me' => 'https://example.com', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'state' => '123456', + 'scopes' => 'create update delete', + 'code_challenge' => '123456', + 'code_challenge_method' => 'S256', + ]); + + $response = $this->actingAs($user)->get($url); + + $response->assertStatus(200); + $response->assertViewIs('indieauth.start'); + } + + #[Test] + public function itShouldErrorIfClientIdPageHasNoValidRedirect(): void + { + // Mock Guzzle request for client_id + $appPageHtml = <<<'HTML' + + + + + Example App + + + + + + HTML; + + $mockHandler = new MockHandler([ + new Response(200, [], $appPageHtml), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $mockGuzzleClient = new Client(['handler' => $handlerStack]); + $this->app->instance(Client::class, $mockGuzzleClient); + + $user = User::factory()->make(); + $url = url()->query('/auth', [ + 'response_type' => 'code', + 'me' => 'https://example.com', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'state' => '123456', + 'scopes' => 'create update delete', + 'code_challenge' => '123456', + 'code_challenge_method' => 'S256', + ]); + + $response = $this->actingAs($user)->get($url); + + $response->assertStatus(200); + $response->assertViewIs('indieauth.error'); + $response->assertSee('redirect_uri is not valid for this client_id'); + } + + #[Test] + public function itShouldRedirectToAppOnApproval(): void + { + $user = User::factory()->make(); + $response = $this->actingAs($user)->post('/auth/confirm', [ + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'state' => '123456', + 'me' => 'https://example.com', + 'scope' => [ + 'create', + 'update', + 'delete', + ], + 'code_challenge' => '123abc', + 'code_challenge_method' => 'S256', + ]); + + $response->assertStatus(302); + + // Parse the redirect URL and check the query parameters + // the `code` will be random, but we can check its present + // and check the other parameters are correct + $redirectUri = $response->headers->get('Location'); + $resolvedRedirectUri = UriResolver::resolve(new Uri('example-app://callback'), new Uri($redirectUri)); + $query = $resolvedRedirectUri->getQuery(); + $parts = explode('&', $query); + $this->assertCount(3, $parts); + $this->assertStringContainsString('code=', $parts[0]); + $this->assertSame('state=123456', $parts[1]); + $this->assertSame('iss=' . config('app.url'), $parts[2]); + } + + #[Test] + public function itShouldShowErrorResponseWhenApprovalRequestIsMissingGrantType(): void + { + $response = $this->post('/auth', [ + 'code' => '123456', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'code_verifier' => '123abc', + ]); + + $response->assertStatus(400); + $response->assertJson([ + 'errors' => [ + 'grant_type' => [ + 'The grant type field is required.', + ], + ], + ]); + } + + #[Test] + public function itShouldShowErrorResponseWhenApprovalRequestIsMissingCode(): void + { + $response = $this->post('/auth', [ + 'grant_type' => 'authorization_code', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'code_verifier' => '123abc', + ]); + + $response->assertStatus(400); + $response->assertJson([ + 'errors' => [ + 'code' => [ + 'The code field is required.', + ], + ], + ]); + } + + #[Test] + public function itShouldShowErrorResponseWhenApprovalRequestIsMissingClientId(): void + { + $response = $this->post('/auth', [ + 'grant_type' => 'authorization_code', + 'code' => '123456', + 'redirect_uri' => 'example-app://callback', + 'code_verifier' => '123abc', + ]); + + $response->assertStatus(400); + $response->assertJson([ + 'errors' => [ + 'client_id' => [ + 'The client id field is required.', + ], + ], + ]); + } + + #[Test] + public function itShouldShowErrorResponseWhenApprovalRequestIsMissingRedirectUri(): void + { + $response = $this->post('/auth', [ + 'grant_type' => 'authorization_code', + 'code' => '123456', + 'client_id' => 'https://app.example.com', + 'code_verifier' => '123abc', + ]); + + $response->assertStatus(400); + $response->assertJson([ + 'errors' => [ + 'redirect_uri' => [ + 'The redirect uri field is required.', + ], + ], + ]); + } + + #[Test] + public function itShouldShowErrorResponseWhenApprovalRequestIsMissingCodeVerifier(): void + { + $response = $this->post('/auth', [ + 'grant_type' => 'authorization_code', + 'code' => '123456', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + ]); + + $response->assertStatus(400); + $response->assertJson([ + 'errors' => [ + 'code_verifier' => [ + 'The code verifier field is required.', + ], + ], + ]); + } + + #[Test] + public function itShouldShowErrorResponseWhenApprovalRequestGrantTypeIsUnsupported(): void + { + $response = $this->post('/auth', [ + 'grant_type' => 'unsupported', + 'code' => '123456', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'code_verifier' => '123abc', + ]); + + $response->assertStatus(400); + $response->assertJson([ + 'errors' => [ + 'grant_type' => [ + 'Only a grant type of "authorization_code" is supported.', + ], + ], + ]); + } + + #[Test] + public function itShouldReturnErrorForUnknownCode(): void + { + $response = $this->post('/auth', [ + 'grant_type' => 'authorization_code', + 'code' => '123456', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'code_verifier' => '123abc', + ]); + + $response->assertStatus(404); + $response->assertJson([ + 'errors' => [ + 'code' => [ + 'The code is invalid.', + ], + ], + ]); + } + + #[Test] + public function itShouldReturnErrorForInvalidCode(): void + { + Cache::shouldReceive('pull') + ->once() + ->with(hash('xxh3', 'https://app.example.com')) + ->andReturn(['auth_code' => 'some value']); + + $response = $this->post('/auth', [ + 'grant_type' => 'authorization_code', + 'code' => '123456', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'code_verifier' => '123abc', + ]); + + $response->assertStatus(400); + $response->assertJson([ + 'errors' => [ + 'code' => [ + 'The code is invalid.', + ], + ], + ]); + } + + #[Test] + public function itShouldReturnErrorForInvalidCodeVerifier(): void + { + Cache::shouldReceive('pull') + ->once() + ->with(hash('xxh3', 'https://app.example.com')) + ->andReturn([ + 'auth_code' => '123456', + 'code_challenge' => '123abc', + ]); + + $response = $this->post('/auth', [ + 'grant_type' => 'authorization_code', + 'code' => '123456', // Matches auth_code we have put in the Cache + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'code_verifier' => 'invalid_value', + ]); + + $response->assertStatus(400); + $response->assertJson([ + 'errors' => [ + 'code_verifier' => [ + 'The code verifier is invalid.', + ], + ], + ]); + } + + #[Test] + public function itShouldReturnMeDataForValidRequest(): void + { + Cache::shouldReceive('pull') + ->once() + ->with(hash('xxh3', 'https://app.example.com')) + ->andReturn([ + 'auth_code' => '123456', + 'code_challenge' => sodium_bin2base64( + hash('sha256', 'abc123def', true), + SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING + ), + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + ]); + + $response = $this->post('/auth', [ + 'grant_type' => 'authorization_code', + 'code' => '123456', // Matches auth_code we have put in the Cache + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'code_verifier' => 'abc123def', + ]); + + $response->assertStatus(200); + $response->assertJson([ + 'me' => config('app.url'), + ]); + } + + #[Test] + public function itShouldReturnErrorWhenNoScopesGivenToTokenEndpoint(): void + { + Cache::shouldReceive('pull') + ->once() + ->with(hash('xxh3', 'https://app.example.com')) + ->andReturn([ + 'auth_code' => '123456', + 'code_challenge' => sodium_bin2base64( + hash('sha256', 'abc123def', true), + SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING + ), + 'scopes' => '', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + ]); + + $response = $this->post('/token', [ + 'grant_type' => 'authorization_code', + 'code' => '123456', // Matches auth_code we have put in the Cache + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'code_verifier' => 'abc123def', + ]); + + $response->assertStatus(400); + $response->assertJson([ + 'errors' => [ + 'scope' => [ + 'The scope property must be non-empty for an access token to be issued.', + ], + ], + ]); + } + + #[Test] + public function itShouldReturnErrorWhenClientIdDoesNotMatchDuringTokenRequest(): void + { + Cache::shouldReceive('pull') + ->once() + ->with(hash('xxh3', 'https://app.example.com')) + ->andReturn([ + 'auth_code' => '123456', + 'code_challenge' => sodium_bin2base64( + hash('sha256', 'abc123def', true), + SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING + ), + 'scopes' => 'create update', + 'client_id' => 'https://app.example.invalid', + 'redirect_uri' => 'example-app://callback', + ]); + + $response = $this->post('/token', [ + 'grant_type' => 'authorization_code', + 'code' => '123456', // Matches auth_code we have put in the Cache + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'code_verifier' => 'abc123def', + ]); + + $response->assertStatus(400); + $response->assertJson([ + 'errors' => [ + 'client_id' => [ + 'The client id is invalid.', + ], + ], + ]); + } + + #[Test] + public function itShouldReturnAnAccessTokenIfValidationPasses(): void + { + Cache::shouldReceive('pull') + ->once() + ->with(hash('xxh3', 'https://app.example.com')) + ->andReturn([ + 'auth_code' => '123456', + 'code_challenge' => sodium_bin2base64( + hash('sha256', 'abc123def', true), + SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING + ), + 'scopes' => 'create update', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + ]); + + $response = $this->post('/token', [ + 'grant_type' => 'authorization_code', + 'code' => '123456', // Matches auth_code we have put in the Cache + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'code_verifier' => 'abc123def', + ]); + + $response->assertStatus(200); + $response->assertJson([ + 'token_type' => 'Bearer', + 'scope' => 'create update', + 'me' => config('app.url'), + ]); + } } diff --git a/tests/Feature/TokenEndpointTest.php b/tests/Feature/TokenEndpointTest.php deleted file mode 100644 index 1a6d05c0..00000000 --- a/tests/Feature/TokenEndpointTest.php +++ /dev/null @@ -1,73 +0,0 @@ - config('app.url'), - 'scope' => 'create update', - ], JSON_THROW_ON_ERROR)), - ]); - $handlerStack = HandlerStack::create($mockHandler); - $mockGuzzleClient = new Client(['handler' => $handlerStack]); - $this->app->instance(Client::class, $mockGuzzleClient); - $response = $this->post('/api/token', [ - 'grant_type' => 'authorization_code', - 'code' => '1234567890', - 'redirect_uri' => 'https://example.com/auth/callback', - 'client_id' => 'https://example.com', - 'code_verifier' => '1234567890', - ]); - - $this->assertSame(config('app.url'), $response->json('me')); - $this->assertNotEmpty($response->json('access_token')); - } - - /** - * @test - * - * @throws JsonException - * @throws Exception - */ - public function tokenEndpointReturnsErrorWhenAuthEndpointLacksMeData(): void - { - $mockHandler = new MockHandler([ - new \GuzzleHttp\Psr7\Response(400, [], json_encode([ - 'error' => 'error_message', - ], JSON_THROW_ON_ERROR)), - ]); - $handlerStack = HandlerStack::create($mockHandler); - $mockGuzzleClient = new Client(['handler' => $handlerStack]); - $this->app->instance(Client::class, $mockGuzzleClient); - $response = $this->post('/api/token', [ - 'me' => config('app.url'), - 'code' => 'abc123', - 'redirect_uri' => config('app.url') . '/indieauth-callback', - 'client_id' => config('app.url') . '/micropub-client', - 'state' => random_int(1000, 10000), - ]); - $response->assertStatus(401); - $response->assertJson([ - 'error' => 'There was an error verifying the IndieAuth code', - ]); - } -} From 58b31bb4c18591dfc4c821669944b3573f3398da Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 8 Jun 2024 19:39:09 +0100 Subject: [PATCH 3/4] Add Indieweb related link to the HTTP headers --- app/Http/Middleware/LinkHeadersMiddleware.php | 9 ++++--- bootstrap/app.php | 19 ++++++++------ config/url.php | 11 -------- resources/views/master.blade.php | 5 ++-- routes/web.php | 6 ++--- tests/Feature/HeaderLinkTest.php | 25 +++++++++++++++++++ 6 files changed, 47 insertions(+), 28 deletions(-) create mode 100644 tests/Feature/HeaderLinkTest.php diff --git a/app/Http/Middleware/LinkHeadersMiddleware.php b/app/Http/Middleware/LinkHeadersMiddleware.php index 66896428..879020be 100644 --- a/app/Http/Middleware/LinkHeadersMiddleware.php +++ b/app/Http/Middleware/LinkHeadersMiddleware.php @@ -16,10 +16,11 @@ class LinkHeadersMiddleware public function handle(Request $request, Closure $next): Response { $response = $next($request); - $response->header('Link', '; rel="authorization_endpoint"', false); - $response->header('Link', '<' . config('app.url') . '/api/token>; rel="token_endpoint"', false); - $response->header('Link', '<' . config('app.url') . '/api/post>; rel="micropub"', false); - $response->header('Link', '<' . config('app.url') . '/webmention>; rel="webmention"', 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.token') . '>; rel="token_endpoint"', false); + $response->header('Link', '<' . route('micropub-endpoint') . '>; rel="micropub"', false); + $response->header('Link', '<' . route('webmention-endpoint') . '>; rel="webmention"', false); return $response; } diff --git a/bootstrap/app.php b/bootstrap/app.php index 3e55ca98..6137bc86 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ withMiddleware(function (Middleware $middleware) { - $middleware->validateCsrfTokens(except: [ - 'auth', // This is the IndieAuth auth endpoint - 'token', // This is the IndieAuth token endpoint - 'api/post', - 'api/media', - 'micropub/places', - 'webmention', - ]); + $middleware + ->append(LinkHeadersMiddleware::class) + ->validateCsrfTokens(except: [ + 'auth', // This is the IndieAuth auth endpoint + 'token', // This is the IndieAuth token endpoint + 'api/post', + 'api/media', + 'micropub/places', + 'webmention', + ]); }) ->withExceptions(function (Exceptions $exceptions) { // diff --git a/config/url.php b/config/url.php index a1962ade..dfdffe6b 100644 --- a/config/url.php +++ b/config/url.php @@ -29,15 +29,4 @@ return [ 'shorturl' => env('APP_SHORTURL', 'shorturl.local'), - /* - |-------------------------------------------------------------------------- - | Authorization endpoint - |-------------------------------------------------------------------------- - | - | The authorization endpoint for the application, used primarily for Micropub - | - */ - - 'authorization_endpoint' => env('AUTHORIZATION_ENDPOINT', 'https://indieauth.com/auth'), - ]; diff --git a/resources/views/master.blade.php b/resources/views/master.blade.php index 5c4f09d9..c0123468 100644 --- a/resources/views/master.blade.php +++ b/resources/views/master.blade.php @@ -16,8 +16,9 @@ - - + + + diff --git a/routes/web.php b/routes/web.php index aed8e64d..110501a3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -192,7 +192,7 @@ Route::domain(config('url.longurl'))->group(function () { }); // IndieAuth - Route::get('.well-known/indieauth-server', [IndieAuthController::class, 'indieAuthMetadataEndpoint']); + Route::get('.well-known/indieauth-server', [IndieAuthController::class, 'indieAuthMetadataEndpoint'])->name('indieauth.metadata'); Route::get('auth', [IndieAuthController::class, 'start'])->middleware(MyAuthMiddleware::class)->name('indieauth.start'); Route::post('auth/confirm', [IndieAuthController::class, 'confirm'])->middleware(MyAuthMiddleware::class); Route::post('auth', [IndieAuthController::class, 'processCodeExchange']); @@ -200,7 +200,7 @@ Route::domain(config('url.longurl'))->group(function () { // Micropub Endpoints Route::get('api/post', [MicropubController::class, 'get'])->middleware(VerifyMicropubToken::class); - Route::post('api/post', [MicropubController::class, 'post'])->middleware(VerifyMicropubToken::class); + Route::post('api/post', [MicropubController::class, 'post'])->middleware(VerifyMicropubToken::class)->name('micropub-endpoint'); Route::get('api/media', [MicropubMediaController::class, 'getHandler'])->middleware(VerifyMicropubToken::class); Route::post('api/media', [MicropubMediaController::class, 'media']) ->middleware([VerifyMicropubToken::class, CorsHeaders::class]) @@ -208,7 +208,7 @@ Route::domain(config('url.longurl'))->group(function () { Route::options('/api/media', [MicropubMediaController::class, 'mediaOptionsResponse'])->middleware(CorsHeaders::class); // Webmention - Route::get('webmention', [WebMentionsController::class, 'get']); + Route::get('webmention', [WebMentionsController::class, 'get']) ->name('webmention-endpoint'); Route::post('webmention', [WebMentionsController::class, 'receive']); // Contacts diff --git a/tests/Feature/HeaderLinkTest.php b/tests/Feature/HeaderLinkTest.php new file mode 100644 index 00000000..8e220c79 --- /dev/null +++ b/tests/Feature/HeaderLinkTest.php @@ -0,0 +1,25 @@ +get('/'); + + $linkHeaders = $response->headers->allPreserveCaseWithoutCookies()['Link']; + + $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]); + } +} From d98a66f42b3b90d01028365753a52cf2433a60c6 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 8 Jun 2024 19:43:44 +0100 Subject: [PATCH 4/4] Laravel Pint fixes --- routes/web.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/routes/web.php b/routes/web.php index 110501a3..7b4030c8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -24,7 +24,6 @@ use App\Http\Controllers\NotesController; use App\Http\Controllers\PlacesController; use App\Http\Controllers\SearchController; use App\Http\Controllers\ShortURLsController; -use App\Http\Controllers\TokenEndpointController; use App\Http\Controllers\WebMentionsController; use App\Http\Middleware\CorsHeaders; use App\Http\Middleware\MyAuthMiddleware; @@ -208,7 +207,7 @@ Route::domain(config('url.longurl'))->group(function () { Route::options('/api/media', [MicropubMediaController::class, 'mediaOptionsResponse'])->middleware(CorsHeaders::class); // Webmention - Route::get('webmention', [WebMentionsController::class, 'get']) ->name('webmention-endpoint'); + Route::get('webmention', [WebMentionsController::class, 'get'])->name('webmention-endpoint'); Route::post('webmention', [WebMentionsController::class, 'receive']); // Contacts