From d070345041a237e18b5b2338b8133d368c3bc405 Mon Sep 17 00:00:00 2001 From: antoine Date: Wed, 25 Mar 2026 20:19:59 +0100 Subject: [PATCH 1/9] wip passkeys --- composer.json | 1 + demo/app/Models/User.php | 5 +- .../Providers/DemoSharpServiceProvider.php | 7 +- demo/app/Sharp/Profile/ProfileSingleShow.php | 8 +- demo/composer.json | 1 + demo/composer.lock | 1499 +++++++++++++---- ...026_03_25_174645_create_passkeys_table.php | 32 + demo/routes/web.php | 5 +- package-lock.json | 12 + package.json | 1 + resources/js/Pages/Auth/Login.vue | 69 +- resources/js/Pages/Auth/Passkeys/Create.vue | 129 ++ resources/js/api/interceptors.ts | 2 + .../js/components/ui/field/FieldError.vue | 7 + resources/js/components/ui/field/index.ts | 1 + .../js/entity-list/components/EntityList.vue | 4 +- resources/lang/en/pages/auth/login.php | 1 + resources/lang/en/pages/auth/passkeys.php | 16 + resources/lang/fr/pages/auth/login.php | 1 + resources/lang/fr/pages/auth/passkeys.php | 16 + resources/views/app.blade.php | 2 +- .../Commands/UpdatePasskeyNameCommand.php | 44 + src/Auth/Passkeys/PasskeyEntity.php | 12 + src/Auth/Passkeys/PasskeyList.php | 97 ++ src/Config/SharpConfigBuilder.php | 57 +- .../Api/ApiEntityListController.php | 14 +- src/Http/Controllers/Auth/LoginController.php | 15 + .../Auth/Passkeys/PasskeyController.php | 83 + .../Passkeys/PasskeySkipPromptController.php | 16 + src/Http/Middleware/HandleInertiaRequests.php | 3 + src/SharpInternalServiceProvider.php | 4 + src/routes/auth/passkeys.php | 22 + tests/Http/Auth/PasskeysTest.php | 105 ++ 33 files changed, 1950 insertions(+), 341 deletions(-) create mode 100644 demo/database/migrations/2026_03_25_174645_create_passkeys_table.php create mode 100644 resources/js/Pages/Auth/Passkeys/Create.vue create mode 100644 resources/js/components/ui/field/FieldError.vue create mode 100644 resources/js/components/ui/field/index.ts create mode 100644 resources/lang/en/pages/auth/passkeys.php create mode 100644 resources/lang/fr/pages/auth/passkeys.php create mode 100644 src/Auth/Passkeys/Commands/UpdatePasskeyNameCommand.php create mode 100644 src/Auth/Passkeys/PasskeyEntity.php create mode 100644 src/Auth/Passkeys/PasskeyList.php create mode 100644 src/Http/Controllers/Auth/Passkeys/PasskeyController.php create mode 100644 src/Http/Controllers/Auth/Passkeys/PasskeySkipPromptController.php create mode 100644 src/routes/auth/passkeys.php create mode 100644 tests/Http/Auth/PasskeysTest.php diff --git a/composer.json b/composer.json index 46f9defa8..f0218d100 100644 --- a/composer.json +++ b/composer.json @@ -43,6 +43,7 @@ "pestphp/pest-plugin-laravel": "^3.0|^4.0", "phpunit/phpunit": "^11.0|^12.0", "spatie/laravel-ray": "^1.26", + "spatie/laravel-passkeys": "^1.0", "spatie/laravel-typescript-transformer": "^2.3", "spatie/typescript-transformer": "^2.2" }, diff --git a/demo/app/Models/User.php b/demo/app/Models/User.php index 3cead3d10..dd1c20d8d 100644 --- a/demo/app/Models/User.php +++ b/demo/app/Models/User.php @@ -7,10 +7,13 @@ use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Spatie\LaravelPasskeys\Models\Concerns\HasPasskeys; +use Spatie\LaravelPasskeys\Models\Concerns\InteractsWithPasskeys; -class User extends Authenticatable +class User extends Authenticatable implements HasPasskeys { use HasFactory; + use InteractsWithPasskeys; use Notifiable; protected $guarded = []; diff --git a/demo/app/Providers/DemoSharpServiceProvider.php b/demo/app/Providers/DemoSharpServiceProvider.php index 0ae63a48d..e3977ce8f 100644 --- a/demo/app/Providers/DemoSharpServiceProvider.php +++ b/demo/app/Providers/DemoSharpServiceProvider.php @@ -23,14 +23,15 @@ protected function configureSharp(SharpConfigBuilder $config): void ->setSharpMenu(SharpMenu::class) ->setThemeColor('#004c9b') ->setThemeLogo(logoUrl: '/img/sharp/logo.svg', logoHeight: '1rem', faviconUrl: '/img/sharp/favicon-32x32.png') - ->enableImpersonation() + // ->enableImpersonation() ->enableForgottenPassword() ->setAuthCustomGuard('web') + ->enable2faCustom(Demo2faNotificationHandler::class) + ->enableLoginRateLimiting(maxAttempts: 3) + ->enablePasskeys() ->setLoginAttributes('email', 'password') ->setUserDisplayAttribute('name') ->setUserAvatarAttribute(fn () => auth()->user()->avatar?->thumbnail(200)) - ->enable2faCustom(Demo2faNotificationHandler::class) - ->enableLoginRateLimiting(maxAttempts: 3) ->suggestRememberMeOnLoginForm() ->appendMessageOnLoginForm(view('sharp._login-page-message')) ->enableGlobalSearch(AppSearchEngine::class, 'Search for posts or authors...') diff --git a/demo/app/Sharp/Profile/ProfileSingleShow.php b/demo/app/Sharp/Profile/ProfileSingleShow.php index 07f51acd6..01f7281b5 100644 --- a/demo/app/Sharp/Profile/ProfileSingleShow.php +++ b/demo/app/Sharp/Profile/ProfileSingleShow.php @@ -5,6 +5,8 @@ use App\Sharp\Profile\Commands\Activate2faCommand; use App\Sharp\Profile\Commands\ChangePasswordCommand; use App\Sharp\Profile\Commands\Deactivate2faCommand; +use Code16\Sharp\Auth\Passkeys\PasskeyEntity; +use Code16\Sharp\Show\Fields\SharpShowEntityListField; use Code16\Sharp\Show\Fields\SharpShowPictureField; use Code16\Sharp\Show\Fields\SharpShowTextField; use Code16\Sharp\Show\Layout\ShowLayout; @@ -25,6 +27,9 @@ protected function buildShowFields(FieldsContainer $showFields): void ) ->addField( SharpShowPictureField::make('avatar'), + ) + ->addField( + SharpShowEntityListField::make(PasskeyEntity::class) ); } @@ -35,7 +40,8 @@ protected function buildShowLayout(ShowLayout $showLayout): void $section ->addColumn(6, fn (ShowLayoutColumn $column) => $column->withField('email')) ->addColumn(6, fn (ShowLayoutColumn $column) => $column->withField('avatar')); - }); + }) + ->addEntityListSection(PasskeyEntity::class); } public function buildShowConfig(): void diff --git a/demo/composer.json b/demo/composer.json index a708fa804..f60888a46 100644 --- a/demo/composer.json +++ b/demo/composer.json @@ -16,6 +16,7 @@ "masterminds/html5": "^2.9", "pragmarx/google2fa": "^8.0", "spatie/image-optimizer": "^1.7", + "spatie/laravel-passkeys": "^1.6", "spatie/laravel-translatable": "^6.13", "symfony/html-sanitizer": "^7.3", "technikermathe/blade-lucide-icons": "dev-l13-compatibility", diff --git a/demo/composer.lock b/demo/composer.lock index 92f7a268f..c08eb5c64 100644 --- a/demo/composer.lock +++ b/demo/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": "8a3a7304ef7bba40660d6e98d1a0bef0", + "content-hash": "2221840f079c1be1388507f2013756dc", "packages": [ { "name": "bacon/bacon-qr-code", @@ -459,6 +459,54 @@ }, "time": "2024-07-08T12:26:09+00:00" }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "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.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, { "name": "doctrine/inflector", "version": "2.1.0", @@ -3264,6 +3312,181 @@ }, "time": "2025-09-24T15:06:41+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.6.7", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "31a105931bc8ffa3a123383829772e832fd8d903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/31a105931bc8ffa3a123383829772e832fd8d903", + "reference": "31a105931bc8ffa3a123383829772e832fd8d903", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^1.7", + "phpstan/phpdoc-parser": "^1.7|^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26" + }, + "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": "opensource@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.6.7" + }, + "time": "2026-03-18T20:47:46+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "1.12.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.3 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^1.18|^2.0" + }, + "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.12.0" + }, + "time": "2025-11-21T15:09:14+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -3339,6 +3562,53 @@ ], "time": "2025-12-27T19:41:33+00:00" }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.2", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "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/2.3.2" + }, + "time": "2026-01-25T14:56:51+00:00" + }, { "name": "pragmarx/google2fa", "version": "v8.0.3", @@ -4197,16 +4467,94 @@ "time": "2026-02-21T12:49:54+00:00" }, { - "name": "spatie/laravel-translatable", - "version": "6.13.0", + "name": "spatie/laravel-passkeys", + "version": "1.6.4", "source": { "type": "git", - "url": "https://github.com/spatie/laravel-translatable.git", - "reference": "f2c5b8805a2dd22799c9aa8ce66cd98ce3170dcb" + "url": "https://github.com/spatie/laravel-passkeys.git", + "reference": "7c9825a193107b951e2016f371a73d4b634986e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-translatable/zipball/f2c5b8805a2dd22799c9aa8ce66cd98ce3170dcb", + "url": "https://api.github.com/repos/spatie/laravel-passkeys/zipball/7c9825a193107b951e2016f371a73d4b634986e0", + "reference": "7c9825a193107b951e2016f371a73d4b634986e0", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^11.0|^12.0|^13.0", + "php": "^8.2|^8.3|^8.4", + "spatie/laravel-package-tools": "^1.16", + "web-auth/webauthn-lib": "^5.0|5.3.x-dev as 5.3.0" + }, + "require-dev": { + "larastan/larastan": "^3.4", + "laravel/pint": "^1.14", + "livewire/livewire": "^3.5 || ^4.0", + "nunomaduro/collision": "^8.1.1", + "orchestra/testbench": "^10.0|^11.0", + "pestphp/pest": "^3.0|^4.0", + "pestphp/pest-plugin-arch": "^3.0|^4.0", + "pestphp/pest-plugin-laravel": "^3.0|^4.0", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "spatie/laravel-ray": "^1.35" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\LaravelPasskeys\\LaravelPasskeysServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\LaravelPasskeys\\": "src/", + "Spatie\\LaravelPasskeys\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Use passkeys in your Laravel app", + "homepage": "https://github.com/spatie/laravel-passkeys", + "keywords": [ + "laravel", + "laravel-passkeys", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-passkeys/issues", + "source": "https://github.com/spatie/laravel-passkeys/tree/1.6.4" + }, + "funding": [ + { + "url": "https://github.com/Spatie", + "type": "github" + } + ], + "time": "2026-03-25T08:24:34+00:00" + }, + { + "name": "spatie/laravel-translatable", + "version": "6.13.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-translatable.git", + "reference": "f2c5b8805a2dd22799c9aa8ce66cd98ce3170dcb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-translatable/zipball/f2c5b8805a2dd22799c9aa8ce66cd98ce3170dcb", "reference": "f2c5b8805a2dd22799c9aa8ce66cd98ce3170dcb", "shasum": "" }, @@ -4279,6 +4627,187 @@ ], "time": "2026-02-21T14:20:19+00:00" }, + { + "name": "spomky-labs/cbor-php", + "version": "3.2.2", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/cbor-php.git", + "reference": "2a5fb86aacfe1004611370ead6caa2bfc88435d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/2a5fb86aacfe1004611370ead6caa2bfc88435d0", + "reference": "2a5fb86aacfe1004611370ead6caa2bfc88435d0", + "shasum": "" + }, + "require": { + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14", + "ext-mbstring": "*", + "php": ">=8.0" + }, + "require-dev": { + "ext-json": "*", + "roave/security-advisories": "dev-latest", + "symfony/error-handler": "^6.4|^7.1|^8.0", + "symfony/var-dumper": "^6.4|^7.1|^8.0" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath extensions will drastically improve the library performance. BCMath extension needed to handle the Big Float and Decimal Fraction Tags", + "ext-gmp": "GMP or BCMath extensions will drastically improve the library performance" + }, + "type": "library", + "autoload": { + "psr-4": { + "CBOR\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/Spomky-Labs/cbor-php/contributors" + } + ], + "description": "CBOR Encoder/Decoder for PHP", + "keywords": [ + "Concise Binary Object Representation", + "RFC7049", + "cbor" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/cbor-php/issues", + "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.2.2" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2025-11-13T13:00:34+00:00" + }, + { + "name": "spomky-labs/pki-framework", + "version": "1.4.2", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/aa576cbd07128075bef97ac2f8af9854e67513d8", + "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "ext-mbstring": "*", + "php": ">=8.1", + "psr/clock": "^1.0" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29|^0.31|^0.32", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0|^13.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symplify/easy-coding-standard": "^12.0|^13.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.2" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-03-23T22:56:56+00:00" + }, { "name": "symfony/clock", "version": "v8.0.0", @@ -6131,35 +6660,31 @@ "time": "2026-01-26T15:08:38+00:00" }, { - "name": "symfony/routing", - "version": "v8.0.6", + "name": "symfony/property-access", + "version": "v8.0.4", "source": { "type": "git", - "url": "https://github.com/symfony/routing.git", - "reference": "053c40fd46e1d19c5c5a94cada93ce6c3facdd55" + "url": "https://github.com/symfony/property-access.git", + "reference": "a35a5ec85b605d0d1a9fd802cb44d87682c746fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/053c40fd46e1d19c5c5a94cada93ce6c3facdd55", - "reference": "053c40fd46e1d19c5c5a94cada93ce6c3facdd55", + "url": "https://api.github.com/repos/symfony/property-access/zipball/a35a5ec85b605d0d1a9fd802cb44d87682c746fd", + "reference": "a35a5ec85b605d0d1a9fd802cb44d87682c746fd", "shasum": "" }, "require": { "php": ">=8.4", - "symfony/deprecation-contracts": "^2.5|^3" + "symfony/property-info": "^7.4.4|^8.0.4" }, "require-dev": { - "psr/log": "^1|^2|^3", - "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", - "symfony/yaml": "^7.4|^8.0" + "symfony/cache": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Routing\\": "" + "Symfony\\Component\\PropertyAccess\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -6179,16 +6704,21 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Maps an HTTP request to a set of configuration variables", + "description": "Provides functions to read and write from/to an object or array using a simple string notation", "homepage": "https://symfony.com", "keywords": [ - "router", - "routing", - "uri", - "url" + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.0.6" + "source": "https://github.com/symfony/property-access/tree/v8.0.4" }, "funding": [ { @@ -6208,46 +6738,45 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:59:43+00:00" + "time": "2026-01-05T09:27:50+00:00" }, { - "name": "symfony/service-contracts", - "version": "v3.6.1", + "name": "symfony/property-info", + "version": "v8.0.7", "source": { "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "url": "https://github.com/symfony/property-info.git", + "reference": "e1a6b5d10ee3455ae698c4a3f4ef580b78af27ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/property-info/zipball/e1a6b5d10ee3455ae698c4a3f4ef580b78af27ba", + "reference": "e1a6b5d10ee3455ae698c4a3f4ef580b78af27ba", "shasum": "" }, "require": { - "php": ">=8.1", - "psr/container": "^1.1|^2.0", - "symfony/deprecation-contracts": "^2.5|^3" + "php": ">=8.4", + "symfony/string": "^7.4|^8.0", + "symfony/type-info": "^7.4.7|^8.0.7" }, "conflict": { - "ext-psr": "<1.1|>=2" + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.6-dev" - } + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" }, + "type": "library", "autoload": { "psr-4": { - "Symfony\\Contracts\\Service\\": "" + "Symfony\\Component\\PropertyInfo\\": "" }, "exclude-from-classmap": [ - "/Test/" + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -6256,26 +6785,26 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Generic abstractions related to writing services", + "description": "Extracts information about PHP class' properties using metadata of popular sources", "homepage": "https://symfony.com", "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/property-info/tree/v8.0.7" }, "funding": [ { @@ -6295,46 +6824,38 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-03-04T15:54:04+00:00" }, { - "name": "symfony/string", + "name": "symfony/routing", "version": "v8.0.6", "source": { "type": "git", - "url": "https://github.com/symfony/string.git", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" + "url": "https://github.com/symfony/routing.git", + "reference": "053c40fd46e1d19c5c5a94cada93ce6c3facdd55" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "url": "https://api.github.com/repos/symfony/routing/zipball/053c40fd46e1d19c5c5a94cada93ce6c3facdd55", + "reference": "053c40fd46e1d19c5c5a94cada93ce6c3facdd55", "shasum": "" }, "require": { "php": ">=8.4", - "symfony/polyfill-ctype": "^1.8", - "symfony/polyfill-intl-grapheme": "^1.33", - "symfony/polyfill-intl-normalizer": "^1.0", - "symfony/polyfill-mbstring": "^1.0" - }, - "conflict": { - "symfony/translation-contracts": "<2.5" + "symfony/deprecation-contracts": "^2.5|^3" }, "require-dev": { - "symfony/emoji": "^7.4|^8.0", - "symfony/http-client": "^7.4|^8.0", - "symfony/intl": "^7.4|^8.0", - "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^7.4|^8.0" + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" }, "type": "library", "autoload": { - "files": [ - "Resources/functions.php" - ], "psr-4": { - "Symfony\\Component\\String\\": "" + "Symfony\\Component\\Routing\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -6346,8 +6867,280 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v8.0.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-02-25T16:59:43+00:00" + }, + { + "name": "symfony/serializer", + "version": "v8.0.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/serializer.git", + "reference": "18bbaf7317e33e7e4bcd7ef281357ec4335fc900" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/serializer/zipball/18bbaf7317e33e7e4bcd7ef281357ec4335fc900", + "reference": "18bbaf7317e33e7e4bcd7ef281357ec4335fc900", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/property-info": "<7.4", + "symfony/type-info": "<7.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.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/v8.0.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-06T13:17:40+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T11:30:57+00:00" + }, + { + "name": "symfony/string", + "version": "v8.0.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", @@ -6562,6 +7355,88 @@ ], "time": "2025-07-15T13:41:35+00:00" }, + { + "name": "symfony/type-info", + "version": "v8.0.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "3c7de103dd6cb68be24e155838a64ef4a70ae195" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/3c7de103dd6cb68be24e155838a64ef4a70ae195", + "reference": "3c7de103dd6cb68be24e155838a64ef4a70ae195", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/container": "^1.1|^2.0" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v8.0.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-04T13:55:34+00:00" + }, { "name": "symfony/uid", "version": "v8.0.4", @@ -7002,35 +7877,202 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:49:13+00:00" + "time": "2025-12-27T19:49:13+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "shasum": "" + }, + "require": { + "php": ">=7.0.0" + }, + "require-dev": { + "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2024-11-21T01:49:47+00:00" + }, + { + "name": "web-auth/cose-lib", + "version": "4.5.0", + "source": { + "type": "git", + "url": "https://github.com/web-auth/cose-lib.git", + "reference": "5adac6fe126994a3ee17ed9950efb4947ab132a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/5adac6fe126994a3ee17ed9950efb4947ab132a9", + "reference": "5adac6fe126994a3ee17ed9950efb4947ab132a9", + "shasum": "" + }, + "require": { + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14", + "ext-json": "*", + "ext-openssl": "*", + "php": ">=8.1", + "spomky-labs/pki-framework": "^1.0" + }, + "require-dev": { + "spomky-labs/cbor-php": "^3.2.2" + }, + "suggest": { + "ext-bcmath": "For better performance, please install either GMP (recommended) or BCMath extension", + "ext-gmp": "For better performance, please install either GMP (recommended) or BCMath extension", + "spomky-labs/cbor-php": "For COSE Signature support" + }, + "type": "library", + "autoload": { + "psr-4": { + "Cose\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/cose/contributors" + } + ], + "description": "CBOR Object Signing and Encryption (COSE) For PHP", + "homepage": "https://github.com/web-auth", + "keywords": [ + "COSE", + "RFC8152" + ], + "support": { + "issues": "https://github.com/web-auth/cose-lib/issues", + "source": "https://github.com/web-auth/cose-lib/tree/4.5.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-01-03T14:43:18+00:00" }, { - "name": "voku/portable-ascii", - "version": "2.0.3", + "name": "web-auth/webauthn-lib", + "version": "5.3.x-dev", "source": { "type": "git", - "url": "https://github.com/voku/portable-ascii.git", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + "url": "https://github.com/web-auth/webauthn-lib.git", + "reference": "1ea7e2cae320f04c75212bf019e845d8d7faf950" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/1ea7e2cae320f04c75212bf019e845d8d7faf950", + "reference": "1ea7e2cae320f04c75212bf019e845d8d7faf950", "shasum": "" }, "require": { - "php": ">=7.0.0" - }, - "require-dev": { - "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + "ext-json": "*", + "ext-openssl": "*", + "paragonie/constant_time_encoding": "^2.6|^3.0", + "php": ">=8.2", + "phpdocumentor/reflection-docblock": "^5.3|^6.0", + "psr/clock": "^1.0", + "psr/event-dispatcher": "^1.0", + "psr/log": "^1.0|^2.0|^3.0", + "spomky-labs/cbor-php": "^3.0", + "spomky-labs/pki-framework": "^1.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^3.2", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "web-auth/cose-lib": "^4.2.3" }, "suggest": { - "ext-intl": "Use Intl for transliterator_transliterate() support" + "psr/log-implementation": "Recommended to receive logs from the library", + "symfony/event-dispatcher": "Recommended to use dispatched events", + "web-token/jwt-library": "Mandatory for fetching Metadata Statement from distant sources" }, + "default-branch": true, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/web-auth/webauthn-framework", + "name": "web-auth/webauthn-framework" + } + }, "autoload": { "psr-4": { - "voku\\": "src/voku/" + "Webauthn\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -7039,95 +8081,100 @@ ], "authors": [ { - "name": "Lars Moelleken", - "homepage": "https://www.moelleken.org/" + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/webauthn-library/contributors" } ], - "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", - "homepage": "https://github.com/voku/portable-ascii", + "description": "FIDO2/Webauthn Support For PHP", + "homepage": "https://github.com/web-auth", "keywords": [ - "ascii", - "clean", - "php" + "FIDO2", + "fido", + "webauthn" ], "support": { - "issues": "https://github.com/voku/portable-ascii/issues", - "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.x" }, "funding": [ { - "url": "https://www.paypal.me/moelleken", - "type": "custom" - }, - { - "url": "https://github.com/voku", + "url": "https://github.com/Spomky", "type": "github" }, { - "url": "https://opencollective.com/portable-ascii", - "type": "open_collective" - }, - { - "url": "https://www.patreon.com/voku", + "url": "https://www.patreon.com/FlorentMorselli", "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", - "type": "tidelift" } ], - "time": "2024-11-21T01:49:47+00:00" - } - ], - "packages-dev": [ + "time": "2026-03-22T17:54:03+00:00" + }, { - "name": "doctrine/deprecations", - "version": "1.1.6", + "name": "webmozart/assert", + "version": "2.1.6", "source": { "type": "git", - "url": "https://github.com/doctrine/deprecations.git", - "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + "url": "https://github.com/webmozarts/assert.git", + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", - "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8", + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "phpunit/phpunit": "<=7.5 || >=14" - }, - "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^14", - "phpstan/phpstan": "1.4.10 || 2.1.30", - "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", - "psr/log": "^1 || ^2 || ^3" + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" }, "suggest": { - "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" }, "type": "library", + "extra": { + "branch-alias": { + "dev-feature/2-0": "2.0-dev" + } + }, "autoload": { "psr-4": { - "Doctrine\\Deprecations\\": "src" + "Webmozart\\Assert\\": "src/" } }, "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/", + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], "support": { - "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.1.6" }, - "time": "2026-02-07T07:09:04+00:00" - }, + "time": "2026-02-27T10:28:38+00:00" + } + ], + "packages-dev": [ { "name": "fakerphp/faker", "version": "v1.24.1", @@ -8137,164 +9184,6 @@ ], "time": "2025-08-16T11:10:48+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/type-resolver", - "version": "1.12.0", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", - "shasum": "" - }, - "require": { - "doctrine/deprecations": "^1.0", - "php": "^7.3 || ^8.0", - "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.18|^2.0" - }, - "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.12.0" - }, - "time": "2025-11-21T15:09:14+00:00" - }, - { - "name": "phpstan/phpdoc-parser", - "version": "2.3.2", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "doctrine/annotations": "^2.0", - "nikic/php-parser": "^5.3.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^9.6", - "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/2.3.2" - }, - "time": "2026-01-25T14:56:51+00:00" - }, { "name": "phpunit/php-code-coverage", "version": "12.5.3", diff --git a/demo/database/migrations/2026_03_25_174645_create_passkeys_table.php b/demo/database/migrations/2026_03_25_174645_create_passkeys_table.php new file mode 100644 index 000000000..665163935 --- /dev/null +++ b/demo/database/migrations/2026_03_25_174645_create_passkeys_table.php @@ -0,0 +1,32 @@ +getTable(); + + Schema::create('passkeys', function (Blueprint $table) use ($authenticatableTableName, $authenticatableClass) { + $table->id(); + + $table + ->foreignIdFor($authenticatableClass, 'authenticatable_id') + ->constrained(table: $authenticatableTableName, indexName: 'passkeys_authenticatable_fk') + ->cascadeOnDelete(); + + $table->text('name'); + $table->text('credential_id'); + $table->json('data'); + + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + }); + } +}; diff --git a/demo/routes/web.php b/demo/routes/web.php index fe71fc62e..a8a018281 100644 --- a/demo/routes/web.php +++ b/demo/routes/web.php @@ -1,15 +1,18 @@ $post]); }); diff --git a/package-lock.json b/package-lock.json index 5f4599d22..31446364f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@googlemaps/js-api-loader": "^1.16.8", "@inertiajs/vue3": "^2.2.20", + "@simplewebauthn/browser": "^13.1.0", "@tiptap/core": "^3.3.0", "@tiptap/extension-character-count": "^3.3.0", "@tiptap/extension-code-block": "^3.3.0", @@ -3003,6 +3004,12 @@ "win32" ] }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, "node_modules/@svgdotjs/svg.draggable.js": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@svgdotjs/svg.draggable.js/-/svg.draggable.js-3.0.4.tgz", @@ -23144,6 +23151,11 @@ "dev": true, "optional": true }, + "@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==" + }, "@svgdotjs/svg.draggable.js": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@svgdotjs/svg.draggable.js/-/svg.draggable.js-3.0.4.tgz", diff --git a/package.json b/package.json index 7b11afa46..4674660da 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "dependencies": { "@googlemaps/js-api-loader": "^1.16.8", "@inertiajs/vue3": "^2.2.20", + "@simplewebauthn/browser": "^13.1.0", "@tiptap/core": "^3.3.0", "@tiptap/extension-character-count": "^3.3.0", "@tiptap/extension-code-block": "^3.3.0", diff --git a/resources/js/Pages/Auth/Login.vue b/resources/js/Pages/Auth/Login.vue index 8c1ffc9fb..cadff4123 100644 --- a/resources/js/Pages/Auth/Login.vue +++ b/resources/js/Pages/Auth/Login.vue @@ -15,6 +15,13 @@ import { FormItem } from "@/components/ui/form"; import { Check } from "lucide-vue-next"; import TemplateRenderer from "@/components/TemplateRenderer.vue"; + import { onMounted, ref } from "vue"; + import { + startAuthentication, + browserSupportsWebAuthn, + browserSupportsWebAuthnAutofill + } from "@simplewebauthn/browser"; + import { api } from "@/api/api"; const props = defineProps<{ loginIsEmail: boolean, @@ -23,6 +30,7 @@ login: string | null, password: string | null, }, + passkeyError: string | null, }>(); @@ -30,6 +38,41 @@ login: props.prefill?.login, password: props.prefill?.password, remember: false, + supports_passkeys: browserSupportsWebAuthn(), + }); + + const passkeyForm = useForm({ + remember: false, + start_authentication_response: '', + }); + + async function loginWithPasskey(autofill = false) { + try { + const response = await api.get(route('passkeys.authentication_options'), { + ignoreContentType: true, + }); + + const authenticationOptions = response.data; + const authenticationResponse = await startAuthentication({ + optionsJSON: authenticationOptions, + useBrowserAutofill: autofill, + }); + + console.log(authenticationResponse); + + passkeyForm.remember = form.remember; + passkeyForm.start_authentication_response = JSON.stringify(authenticationResponse); + + passkeyForm.post(route('passkeys.login')); + } catch (error) { + console.error(error); + } + } + + onMounted(async () => { + if(config('sharp.auth.passkeys.enabled') && await browserSupportsWebAuthnAutofill()) { + await loginWithPasskey(true); + } }); @@ -39,10 +82,10 @@ {{ __('sharp::pages/auth/login.title') }} - - + diff --git a/resources/js/Pages/Auth/Passkeys/Create.vue b/resources/js/Pages/Auth/Passkeys/Create.vue new file mode 100644 index 000000000..a900d6611 --- /dev/null +++ b/resources/js/Pages/Auth/Passkeys/Create.vue @@ -0,0 +1,129 @@ + + + diff --git a/resources/js/api/interceptors.ts b/resources/js/api/interceptors.ts index 69cd127d6..8bf80afef 100644 --- a/resources/js/api/interceptors.ts +++ b/resources/js/api/interceptors.ts @@ -5,6 +5,7 @@ import { Axios, AxiosError, isCancel } from "axios"; declare module 'axios' { interface AxiosRequestConfig { preloaded?: boolean; + ignoreContentType?: boolean; } } @@ -31,6 +32,7 @@ export function installInterceptors(api: Axios) { response => { if(!response.headers['content-type']?.includes('application/json') && !response.headers['content-disposition']?.includes('attachment') + && !response.config.ignoreContentType ) { throw new Error( `${response.config.method.toUpperCase()} ${response.config.url} :` + diff --git a/resources/js/components/ui/field/FieldError.vue b/resources/js/components/ui/field/FieldError.vue new file mode 100644 index 000000000..8abff3e50 --- /dev/null +++ b/resources/js/components/ui/field/FieldError.vue @@ -0,0 +1,7 @@ + + diff --git a/resources/js/components/ui/field/index.ts b/resources/js/components/ui/field/index.ts new file mode 100644 index 000000000..10b62d43f --- /dev/null +++ b/resources/js/components/ui/field/index.ts @@ -0,0 +1 @@ +export { default as FieldError } from './FieldError.vue'; diff --git a/resources/js/entity-list/components/EntityList.vue b/resources/js/entity-list/components/EntityList.vue index 79e231b5f..3d7a5c74f 100644 --- a/resources/js/entity-list/components/EntityList.vue +++ b/resources/js/entity-list/components/EntityList.vue @@ -248,7 +248,9 @@ if(await showDeleteConfirm(entityList.config.deleteConfirmationText, { highlightElement: () => el.value?.querySelector(`[data-instance-row="${instanceId}"]`) as HTMLElement, })) { - await api.delete(route('code16.sharp.api.list.delete', { entityKey, instanceId })); + await api.delete(route('code16.sharp.api.list.delete', { entityKey, instanceId }), { + params: { ...props.entityList.query }, + }); commands.handleCommandResponse({ action: 'reload' }); } } diff --git a/resources/lang/en/pages/auth/login.php b/resources/lang/en/pages/auth/login.php index 7b06c2d0d..058b2430b 100644 --- a/resources/lang/en/pages/auth/login.php +++ b/resources/lang/en/pages/auth/login.php @@ -8,5 +8,6 @@ 'code_field' => 'Code', 'remember' => 'Remember me', 'button' => 'Login', + 'passkey_button' => 'Use passkey', 'forgot_password_link' => 'Forgot password?', ]; diff --git a/resources/lang/en/pages/auth/passkeys.php b/resources/lang/en/pages/auth/passkeys.php new file mode 100644 index 000000000..f0be1c0e2 --- /dev/null +++ b/resources/lang/en/pages/auth/passkeys.php @@ -0,0 +1,16 @@ + 'Create a passkey', + 'name_field' => 'Name', + 'prompt_version' => [ + 'description' => 'A passkey is a faster, more secure way to sign in without a password.', + 'button' => 'Create passkey', + 'skip_prompt_button' => 'Remind me later', + 'never_ask_again_button' => "Don't ask me again in this browser", + ], + 'account_version' => [ + 'button' => 'Create passkey', + 'cancel_button' => 'Cancel', + ], +]; diff --git a/resources/lang/fr/pages/auth/login.php b/resources/lang/fr/pages/auth/login.php index 9b4e7a5eb..d54965539 100644 --- a/resources/lang/fr/pages/auth/login.php +++ b/resources/lang/fr/pages/auth/login.php @@ -8,5 +8,6 @@ 'code_field' => 'Code', 'remember' => 'Rester connecté', 'button' => 'Connexion', + 'passkey_button' => 'Utiliser une clé d’accès', 'forgot_password_link' => 'Mot de passe oublié ?', ]; diff --git a/resources/lang/fr/pages/auth/passkeys.php b/resources/lang/fr/pages/auth/passkeys.php new file mode 100644 index 000000000..b53f6acc9 --- /dev/null +++ b/resources/lang/fr/pages/auth/passkeys.php @@ -0,0 +1,16 @@ + 'Créer une clé de sécurité', + 'name_field' => 'Nom', + 'prompt_version' => [ + 'description' => 'Une clé de sécurité est un moyen plus rapide et plus sûr de se connecter sans mot de passe.', + 'button' => 'Créer une clé', + 'skip_prompt_button' => 'Plus tard', + 'never_ask_again_button' => 'Ne plus me demander sur ce navigateur', + ], + 'account_version' => [ + 'button' => 'Créer une clé', + 'cancel_button' => 'Annuler', + ], +]; diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 83c820550..dd0ab9c8d 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -24,7 +24,7 @@ {{-- --}} @php - config()->set('ziggy', ['only' => 'code16.sharp.*', 'skip-route-function' => true]); + config()->set('ziggy', ['only' => ['code16.sharp.*', 'passkeys.*'], 'skip-route-function' => true]); \Tighten\Ziggy\BladeRouteGenerator::$generated = false; // Don't generate "merge script" (https://github.com/code16/sharp/issues/649) @endphp @routes(nonce: \Illuminate\Support\Facades\Vite::cspNonce()) diff --git a/src/Auth/Passkeys/Commands/UpdatePasskeyNameCommand.php b/src/Auth/Passkeys/Commands/UpdatePasskeyNameCommand.php new file mode 100644 index 000000000..6ef0c4ee6 --- /dev/null +++ b/src/Auth/Passkeys/Commands/UpdatePasskeyNameCommand.php @@ -0,0 +1,44 @@ +addField( + SharpFormTextField::make('name') + ->setLabel('Name') + ); + } + + protected function initialData(mixed $instanceId): array + { + return [ + 'name' => Passkey::findOrFail($instanceId)->name, + ]; + } + + public function execute(mixed $instanceId, array $data = []): array + { + $this->validate($data, [ + 'name' => 'required', + ]); + + Passkey::findOrFail($instanceId)->update([ + 'name' => $data['name'], + ]); + + return $this->refresh($instanceId); + } +} diff --git a/src/Auth/Passkeys/PasskeyEntity.php b/src/Auth/Passkeys/PasskeyEntity.php new file mode 100644 index 000000000..4e29ff650 --- /dev/null +++ b/src/Auth/Passkeys/PasskeyEntity.php @@ -0,0 +1,12 @@ +addField( + EntityListField::make('name') + ->setLabel('Name') + ) + ->addField( + EntityListField::make('created_at') + ->setLabel('Creation date') + ) + ->addField( + EntityListField::make('last_used_at') + ->setLabel('Last used at') + ); + } + + public function buildListConfig(): void + { + $this->configurePrimaryEntityCommand('add'); + } + + public function getEntityCommands(): ?array + { + return [ + 'add' => new class() extends EntityCommand + { + public function label(): string + { + return 'Add a passkey'; + } + + public function execute(array $data = []): array + { + redirect()->setIntendedUrl(sharp()->context()->breadcrumb()->getCurrentSegmentUrl()); + + return $this->link(route('code16.sharp.passkeys.create')); + } + }, + ]; + } + + public function getInstanceCommands(): ?array + { + return [ + UpdatePasskeyNameCommand::class, + ]; + } + + protected function getFilters(): ?array + { + return [ + HiddenFilter::make('user_id'), + ]; + } + + public function delete(mixed $id): void + { + $this->currentUser()->passKeys()->findOrFail($id)->delete(); + } + + public function getListData(): array|Arrayable + { + return $this->transform( + $this->currentUser()->passkeys + ); + } + + protected function currentUser(): Authenticatable&HasPasskeys + { + // disabling for now (security concerns) + // if($this->queryParams->filterFor('user_id')) { + // return Config::getAuthenticatableModel()::findOrFail($this->queryParams->filterFor('user_id')); + // } + + /** @var Authenticatable&HasPasskeys $user */ + $user = auth()->user(); + + return $user; + } +} diff --git a/src/Config/SharpConfigBuilder.php b/src/Config/SharpConfigBuilder.php index eec017334..69d2e69e5 100644 --- a/src/Config/SharpConfigBuilder.php +++ b/src/Config/SharpConfigBuilder.php @@ -5,10 +5,17 @@ use Closure; use Code16\Sharp\Auth\Impersonate\SharpDefaultEloquentImpersonationHandler; use Code16\Sharp\Auth\Impersonate\SharpImpersonationHandler; +use Code16\Sharp\Auth\Passkeys\PasskeyEntity; use Code16\Sharp\Auth\TwoFactor\Sharp2faHandler; use Code16\Sharp\Exceptions\SharpInvalidConfigException; use Code16\Sharp\Exceptions\SharpInvalidEntityKeyException; use Code16\Sharp\Filters\GlobalRequiredFilter; +use Code16\Sharp\Http\Middleware\AddLinkHeadersForPreloadedRequests; +use Code16\Sharp\Http\Middleware\Api\HandleSharpApiErrors; +use Code16\Sharp\Http\Middleware\HandleGlobalFilters; +use Code16\Sharp\Http\Middleware\HandleInertiaRequests; +use Code16\Sharp\Http\Middleware\HandleSharpErrors; +use Code16\Sharp\Http\Middleware\InvalidateCache; use Code16\Sharp\Search\SharpSearchEngine; use Code16\Sharp\Utils\Entities\BaseSharpEntity; use Code16\Sharp\Utils\Entities\SharpDashboardEntity; @@ -18,8 +25,16 @@ use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\PasswordBroker; use Illuminate\Contracts\View\View; +use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; +use Illuminate\Cookie\Middleware\EncryptCookies; +use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; use Illuminate\Foundation\Vite; +use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets; +use Illuminate\Routing\Middleware\SubstituteBindings; +use Illuminate\Session\Middleware\StartSession; use Illuminate\Support\Traits\Conditionable; +use Illuminate\View\Middleware\ShareErrorsFromSession; +use Intervention\Image\Drivers\Gd\Driver; use ReflectionClass; use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\SplFileInfo; @@ -46,23 +61,23 @@ class SharpConfigBuilder 'global_filters' => [], 'middleware' => [ 'common' => [ - \Illuminate\Cookie\Middleware\EncryptCookies::class, - \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, - \Illuminate\Session\Middleware\StartSession::class, - \Illuminate\View\Middleware\ShareErrorsFromSession::class, - \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class, - \Code16\Sharp\Http\Middleware\HandleGlobalFilters::class, - \Illuminate\Routing\Middleware\SubstituteBindings::class, + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + ShareErrorsFromSession::class, + VerifyCsrfToken::class, + HandleGlobalFilters::class, + SubstituteBindings::class, ], 'web' => [ - \Code16\Sharp\Http\Middleware\InvalidateCache::class, - \Code16\Sharp\Http\Middleware\HandleSharpErrors::class, - \Code16\Sharp\Http\Middleware\HandleInertiaRequests::class, - \Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class, - \Code16\Sharp\Http\Middleware\AddLinkHeadersForPreloadedRequests::class, + InvalidateCache::class, + HandleSharpErrors::class, + HandleInertiaRequests::class, + AddLinkHeadersForPreloadedAssets::class, + AddLinkHeadersForPreloadedRequests::class, ], 'api' => [ - \Code16\Sharp\Http\Middleware\Api\HandleSharpApiErrors::class, + HandleSharpApiErrors::class, ], ], 'auth' => [ @@ -97,7 +112,7 @@ class SharpConfigBuilder 'transform_keep_original_image' => true, 'max_file_size' => 5, 'model_class' => null, - 'image_driver' => \Intervention\Image\Drivers\Gd\Driver::class, + 'image_driver' => Driver::class, 'file_handling_queue' => 'default', 'file_handling_queue_connection' => 'sync', ], @@ -336,7 +351,7 @@ public function configureUploadsThumbnailCreation( string $thumbnailsDisk = 'public', string $thumbnailsDir = 'thumbnails', ?string $uploadModelClass = null, - string $imageDriverClass = \Intervention\Image\Drivers\Gd\Driver::class, + string $imageDriverClass = Driver::class, ): self { $this->config['uploads']['thumbnails_disk'] = $thumbnailsDisk; $this->config['uploads']['thumbnails_dir'] = $thumbnailsDir; @@ -498,6 +513,18 @@ public function enable2faByTotp(): self return $this; } + public function enablePasskeys(bool $promptAfterLogin = true): self + { + $this->config['auth']['passkeys'] = [ + 'enabled' => true, + 'prompt_after_login' => $promptAfterLogin, + ]; + + $this->declareEntity(PasskeyEntity::class); + + return $this; + } + public function enable2faCustom(string|Sharp2faHandler $customHandler): self { $this->config['auth']['2fa'] = [ diff --git a/src/Http/Controllers/Api/ApiEntityListController.php b/src/Http/Controllers/Api/ApiEntityListController.php index 0311da9e7..5df67a5d6 100644 --- a/src/Http/Controllers/Api/ApiEntityListController.php +++ b/src/Http/Controllers/Api/ApiEntityListController.php @@ -32,19 +32,21 @@ public function delete(string $globalFilter, string $entityKey, string $instance { $this->authorizationManager->check('delete', $entityKey, $instanceId); - $impl = $this->getListInstance($entityKey); - if (! self::isDeleteMethodImplementedInConcreteClass($impl)) { - // Try to delete from Show Page + $list = $this->getListInstance($entityKey); + $list->initQueryParams(request()->query()); + + if (self::isDeleteMethodImplementedInConcreteClass($list)) { + $list->delete($instanceId); + } else { try { - $impl = $this->getShowInstance($entityKey); + $show = $this->getShowInstance($entityKey); + $show->delete($instanceId); } catch (SharpInvalidEntityKeyException $ex) { // No Show Page implementation was defined for this entity throw new SharpMethodNotImplementedException('The delete() method is not implemented, neither in the Entity List nor in the Show Page'); } } - $impl->delete($instanceId); - return response()->json([ 'ok' => true, ]); diff --git a/src/Http/Controllers/Auth/LoginController.php b/src/Http/Controllers/Auth/LoginController.php index 484f088b6..c66f728ba 100644 --- a/src/Http/Controllers/Auth/LoginController.php +++ b/src/Http/Controllers/Auth/LoginController.php @@ -27,6 +27,10 @@ public function create(): RedirectResponse|Response return redirect()->to($loginPageUrl); } + if (sharp()->config()->get('auth.passkeys.enabled')) { + session()->put('passkeys.redirect', route('code16.sharp.home')); + } + $message = sharp()->config()->get('auth.login_form_message'); return Inertia::render('Auth/Login', [ @@ -36,6 +40,7 @@ public function create(): RedirectResponse|Response ? $message->render() : view('sharp::partials.login-form-message', ['message' => $message])->render() : null, + 'passkeyError' => session('authenticatePasskey::message'), ]); } @@ -50,6 +55,16 @@ public function store(LoginRequest $request): RedirectResponse $request->session()->regenerate(); + if (sharp()->config()->get('auth.passkeys.enabled') + && sharp()->config()->get('auth.passkeys.prompt_after_login') + && ! $request->cookie('sharp_skip_passkey_prompt') + && $request->boolean('supports_passkeys') + && method_exists($request->user(), 'passkeys') + && $request->user()->passkeys()->count() === 0 + ) { + return redirect()->route('code16.sharp.passkeys.create', ['prompt' => true]); + } + return redirect()->intended(route('code16.sharp.home')); } diff --git a/src/Http/Controllers/Auth/Passkeys/PasskeyController.php b/src/Http/Controllers/Auth/Passkeys/PasskeyController.php new file mode 100644 index 000000000..3a633eea0 --- /dev/null +++ b/src/Http/Controllers/Auth/Passkeys/PasskeyController.php @@ -0,0 +1,83 @@ + $request->boolean('prompt'), + 'cancelUrl' => redirect()->getIntendedUrl(), + ]); + } + + public function validate(): JsonResponse + { + request()->validate([ + 'name' => 'required|string|max:255', + ]); + + return response()->json([ + 'passkeyOptions' => json_decode($this->generatePasskeyOptions()), + ]); + } + + public function store(Request $request) + { + $passkey = request()->input('passkey'); + $storePasskeyAction = Config::getAction('store_passkey', StorePasskeyAction::class); + + try { + $storePasskeyAction->execute( + $this->currentUser(), + $passkey, $this->previouslyGeneratedPasskeyOptions(), + request()->getHost(), + ['name' => request()->input('name')] + ); + } catch (Throwable $e) { + throw ValidationException::withMessages([ + 'name' => __('passkeys::passkeys.error_something_went_wrong_generating_the_passkey'), + ])->errorBag('passkeyForm'); + } + + return redirect()->intended(route('code16.sharp.home')); + } + + protected function currentUser(): Authenticatable&HasPasskeys + { + /** @var Authenticatable&HasPasskeys $user */ + $user = auth()->user(); + + return $user; + } + + protected function generatePasskeyOptions(): string + { + $generatePassKeyOptionsAction = Config::getAction('generate_passkey_register_options', GeneratePasskeyRegisterOptionsAction::class); + + $options = $generatePassKeyOptionsAction->execute($this->currentUser()); + + session()->put('passkey-registration-options', $options); + + return $options; + } + + protected function previouslyGeneratedPasskeyOptions(): ?string + { + return session()->pull('passkey-registration-options'); + } +} diff --git a/src/Http/Controllers/Auth/Passkeys/PasskeySkipPromptController.php b/src/Http/Controllers/Auth/Passkeys/PasskeySkipPromptController.php new file mode 100644 index 000000000..3a96974eb --- /dev/null +++ b/src/Http/Controllers/Auth/Passkeys/PasskeySkipPromptController.php @@ -0,0 +1,16 @@ +to(route('code16.sharp.home')) + ->withCookie(cookie()->forever('sharp_skip_passkey_prompt', true)); + } +} diff --git a/src/Http/Middleware/HandleInertiaRequests.php b/src/Http/Middleware/HandleInertiaRequests.php index 62067be65..5f84b21ec 100644 --- a/src/Http/Middleware/HandleInertiaRequests.php +++ b/src/Http/Middleware/HandleInertiaRequests.php @@ -76,10 +76,12 @@ public function share(Request $request) 'sharp::pages/auth/login', 'sharp::pages/auth/impersonate', 'sharp::pages/auth/reset-password', + 'sharp::pages/auth/passkeys', 'sharp::show', ]) ->map(fn ($group) => collect(__($group, [], app()->getFallbackLocale())) ->mapWithKeys(fn ($value, $key) => ["$group.$key" => __("$group.$key")]) + ->dot() ) ->collapse() ->toArray(); @@ -89,6 +91,7 @@ public function share(Request $request) 'sharp.auth.forgotten_password.enabled' => sharp()->config()->get('auth.forgotten_password.enabled'), 'sharp.auth.forgotten_password.show_reset_link_in_login_form' => sharp()->config()->get('auth.forgotten_password.show_reset_link_in_login_form'), 'sharp.auth.suggest_remember_me' => sharp()->config()->get('auth.suggest_remember_me'), + 'sharp.auth.passkeys.enabled' => sharp()->config()->get('auth.passkeys.enabled'), 'sharp.custom_url_segment' => sharp()->config()->get('custom_url_segment'), 'sharp.display_sharp_version_in_title' => sharp()->config()->get('display_sharp_version_in_title'), 'sharp.breadcrumb.display' => sharp()->config()->get('breadcrumb.display'), diff --git a/src/SharpInternalServiceProvider.php b/src/SharpInternalServiceProvider.php index 3ce27408c..c660303b3 100644 --- a/src/SharpInternalServiceProvider.php +++ b/src/SharpInternalServiceProvider.php @@ -239,6 +239,10 @@ public function loadRoutes(): void if (sharp()->config()->get('auth.impersonate.enabled')) { $this->loadRoutesFrom(__DIR__.'/routes/auth/impersonate.php'); } + + if (sharp()->config()->get('auth.passkeys.enabled')) { + $this->loadRoutesFrom(__DIR__.'/routes/auth/passkeys.php'); + } } private function configureOctane(): void diff --git a/src/routes/auth/passkeys.php b/src/routes/auth/passkeys.php new file mode 100644 index 000000000..ab0985144 --- /dev/null +++ b/src/routes/auth/passkeys.php @@ -0,0 +1,22 @@ + '/'.sharp()->config()->get('custom_url_segment'), + 'middleware' => ['sharp_common', 'sharp_web', 'sharp_auth'], +], function () { + Route::get('/passkeys/create', [PasskeyController::class, 'create']) + ->name('code16.sharp.passkeys.create'); + + Route::post('/passkeys/validate', [PasskeyController::class, 'validate']) + ->name('code16.sharp.passkeys.validate'); + + Route::post('/passkeys', [PasskeyController::class, 'store']) + ->name('code16.sharp.passkeys.store'); + + Route::post('/passkeys/skip-prompt', PasskeySkipPromptController::class) + ->name('code16.sharp.passkeys.skip-prompt'); +}); diff --git a/tests/Http/Auth/PasskeysTest.php b/tests/Http/Auth/PasskeysTest.php new file mode 100644 index 000000000..a3842b453 --- /dev/null +++ b/tests/Http/Auth/PasskeysTest.php @@ -0,0 +1,105 @@ +extend('sharp', fn () => new TestAuthGuard()); + config()->set('auth.guards.sharp', ['driver' => 'sharp', 'provider' => 'users']); + + sharp()->config() + ->declareEntity(PersonEntity::class) + ->setAuthCustomGuard('sharp') + ->enablePasskeys(); +}); + +it('sets passkeys.redirect session key in login show', function () { + $this->get(route('code16.sharp.login')) + ->assertOk(); + + expect(session()->get('passkeys.redirect'))->toBe(route('code16.sharp.home')); +}); + +it('redirects to passkey creation after login if prompt is enabled, supported and no passkeys', function () { + Route::get('/passkeys/create', [PasskeyController::class, 'create']) + ->name('code16.sharp.passkeys.create'); + + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('passkeys')->andReturn( + Mockery::mock(Collection::class)->shouldReceive('count')->andReturn(0)->getMock() + ); + + $this->actingAs($user, 'sharp') + ->post(route('code16.sharp.login.post'), [ + 'login' => 'test@example.org', + 'password' => 'password', + 'supports_passkeys' => true, + ]) + ->assertRedirect('/passkeys/create?prompt=1'); +}); + +it('does not redirect to passkey creation if user already has passkeys', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('passkeys')->andReturn( + Mockery::mock(Collection::class)->shouldReceive('count')->andReturn(1)->getMock() + ); + + $this->actingAs($user, 'sharp') + ->post(route('code16.sharp.login.post'), [ + 'login' => 'test@example.org', + 'password' => 'password', + 'supports_passkeys' => true, + ]) + ->assertRedirect(route('code16.sharp.home')); +}); + +it('does not redirect to passkey creation if browser does not support passkeys', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('passkeys')->andReturn( + Mockery::mock(Collection::class)->shouldReceive('count')->andReturn(0)->getMock() + ); + + $this->actingAs($user, 'sharp') + ->post(route('code16.sharp.login.post'), [ + 'login' => 'test@example.org', + 'password' => 'password', + 'supports_passkeys' => false, + ]) + ->assertRedirect(route('code16.sharp.home')); +}); + +it('does not redirect to passkey creation if skip cookie is present', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('passkeys')->andReturn( + Mockery::mock(Collection::class)->shouldReceive('count')->andReturn(0)->getMock() + ); + + $this->withCookie('sharp_skip_passkey_prompt', '1') + ->actingAs($user, 'sharp') + ->post(route('code16.sharp.login.post'), [ + 'login' => 'test@example.org', + 'password' => 'password', + 'supports_passkeys' => true, + ]) + ->assertRedirect(route('code16.sharp.home')); +}); + +it('sets skip cookie in PasskeySkipPromptController', function () { + Route::post('/passkeys/skip-prompt', PasskeySkipPromptController::class) + ->name('code16.sharp.passkeys.skip-prompt'); + + $user = new User(['email' => 'test@example.org']); + + $this->actingAs($user, 'sharp') + ->post('/passkeys/skip-prompt') + ->assertRedirect(route('code16.sharp.home')) + ->assertCookie('sharp_skip_passkey_prompt', true); +}); From 4b98580ee058f32835b8a3a8b32ba846999ceb79 Mon Sep 17 00:00:00 2001 From: antoine Date: Thu, 26 Mar 2026 14:14:02 +0100 Subject: [PATCH 2/9] temp fix --- demo/app/Models/Passkey.php | 27 ++++++++++++++++++++ demo/config/passkeys.php | 49 +++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 demo/app/Models/Passkey.php create mode 100644 demo/config/passkeys.php diff --git a/demo/app/Models/Passkey.php b/demo/app/Models/Passkey.php new file mode 100644 index 000000000..bf69d020d --- /dev/null +++ b/demo/app/Models/Passkey.php @@ -0,0 +1,27 @@ + $serializer->fromJson( + $value, + CredentialRecord::class, + ), + set: fn (CredentialRecord $value) => [ + 'credential_id' => self::encodeCredentialId($value->publicKeyCredentialId), + 'data' => $serializer->toJson($value), + ], + ); + } +} diff --git a/demo/config/passkeys.php b/demo/config/passkeys.php new file mode 100644 index 000000000..9d75064e2 --- /dev/null +++ b/demo/config/passkeys.php @@ -0,0 +1,49 @@ + '/dashboard', + + /* + * These class are responsible for performing core tasks regarding passkeys. + * You can customize them by creating a class that extends the default, and + * by specifying your custom class name here. + */ + 'actions' => [ + 'generate_passkey_register_options' => GeneratePasskeyRegisterOptionsAction::class, + 'store_passkey' => StorePasskeyAction::class, + 'generate_passkey_authentication_options' => GeneratePasskeyAuthenticationOptionsAction::class, + 'find_passkey' => FindPasskeyToAuthenticateAction::class, + 'configure_ceremony_step_manager_factory' => ConfigureCeremonyStepManagerFactoryAction::class, + ], + + /* + * These properties will be used to generate the passkey. + */ + 'relying_party' => [ + 'name' => config('app.name'), + 'id' => parse_url(config('app.url'), PHP_URL_HOST), + 'icon' => null, + ], + + /* + * The models used by the package. + * + * You can override this by specifying your own models + */ + 'models' => [ + 'passkey' => Passkey::class, + 'authenticatable' => env('AUTH_MODEL', User::class), + ], +]; From 15f4d9bf785b210775729429603dd8deb1e3a2ca Mon Sep 17 00:00:00 2001 From: antoine Date: Thu, 26 Mar 2026 18:28:18 +0100 Subject: [PATCH 3/9] wip passkeys --- .../Providers/DemoSharpServiceProvider.php | 2 +- demo/app/Sharp/Profile/ProfileSingleShow.php | 8 ++--- demo/config/demo.php | 5 +++ .../js/entity-list/components/EntityList.vue | 2 +- .../Passkeys/{ => Entity}/PasskeyEntity.php | 2 +- .../Passkeys/{ => Entity}/PasskeyList.php | 33 +++++++++---------- src/Auth/Passkeys/PasskeyEventSubscriber.php | 17 ++++++++++ src/Config/SharpConfigBuilder.php | 2 +- src/SharpInternalServiceProvider.php | 3 ++ 9 files changed, 49 insertions(+), 25 deletions(-) create mode 100644 demo/config/demo.php rename src/Auth/Passkeys/{ => Entity}/PasskeyEntity.php (84%) rename src/Auth/Passkeys/{ => Entity}/PasskeyList.php (77%) create mode 100644 src/Auth/Passkeys/PasskeyEventSubscriber.php diff --git a/demo/app/Providers/DemoSharpServiceProvider.php b/demo/app/Providers/DemoSharpServiceProvider.php index e3977ce8f..6d5c06af1 100644 --- a/demo/app/Providers/DemoSharpServiceProvider.php +++ b/demo/app/Providers/DemoSharpServiceProvider.php @@ -28,7 +28,7 @@ protected function configureSharp(SharpConfigBuilder $config): void ->setAuthCustomGuard('web') ->enable2faCustom(Demo2faNotificationHandler::class) ->enableLoginRateLimiting(maxAttempts: 3) - ->enablePasskeys() + ->when(config('demo.enable_passkeys'))->enablePasskeys() ->setLoginAttributes('email', 'password') ->setUserDisplayAttribute('name') ->setUserAvatarAttribute(fn () => auth()->user()->avatar?->thumbnail(200)) diff --git a/demo/app/Sharp/Profile/ProfileSingleShow.php b/demo/app/Sharp/Profile/ProfileSingleShow.php index 01f7281b5..d76e6ade5 100644 --- a/demo/app/Sharp/Profile/ProfileSingleShow.php +++ b/demo/app/Sharp/Profile/ProfileSingleShow.php @@ -5,7 +5,7 @@ use App\Sharp\Profile\Commands\Activate2faCommand; use App\Sharp\Profile\Commands\ChangePasswordCommand; use App\Sharp\Profile\Commands\Deactivate2faCommand; -use Code16\Sharp\Auth\Passkeys\PasskeyEntity; +use Code16\Sharp\Auth\Passkeys\Entity\PasskeyEntity; use Code16\Sharp\Show\Fields\SharpShowEntityListField; use Code16\Sharp\Show\Fields\SharpShowPictureField; use Code16\Sharp\Show\Fields\SharpShowTextField; @@ -28,9 +28,9 @@ protected function buildShowFields(FieldsContainer $showFields): void ->addField( SharpShowPictureField::make('avatar'), ) - ->addField( + ->when(config('demo.enable_passkeys'), fn () => $showFields->addField( SharpShowEntityListField::make(PasskeyEntity::class) - ); + )); } protected function buildShowLayout(ShowLayout $showLayout): void @@ -41,7 +41,7 @@ protected function buildShowLayout(ShowLayout $showLayout): void ->addColumn(6, fn (ShowLayoutColumn $column) => $column->withField('email')) ->addColumn(6, fn (ShowLayoutColumn $column) => $column->withField('avatar')); }) - ->addEntityListSection(PasskeyEntity::class); + ->when(config('demo.enable_passkeys'))->addEntityListSection(PasskeyEntity::class); } public function buildShowConfig(): void diff --git a/demo/config/demo.php b/demo/config/demo.php new file mode 100644 index 000000000..96d1bc084 --- /dev/null +++ b/demo/config/demo.php @@ -0,0 +1,5 @@ + env('DEMO_ENABLE_PASSKEYS', false), +]; diff --git a/resources/js/entity-list/components/EntityList.vue b/resources/js/entity-list/components/EntityList.vue index 3d7a5c74f..b89b5577e 100644 --- a/resources/js/entity-list/components/EntityList.vue +++ b/resources/js/entity-list/components/EntityList.vue @@ -777,7 +777,7 @@
diff --git a/src/Auth/Passkeys/PasskeyEntity.php b/src/Auth/Passkeys/Entity/PasskeyEntity.php similarity index 84% rename from src/Auth/Passkeys/PasskeyEntity.php rename to src/Auth/Passkeys/Entity/PasskeyEntity.php index 4e29ff650..248314b83 100644 --- a/src/Auth/Passkeys/PasskeyEntity.php +++ b/src/Auth/Passkeys/Entity/PasskeyEntity.php @@ -1,6 +1,6 @@ setLabel('Name') ) + ->addField( + EntityListBadgeField::make('usage') + ->setLabel('Usage') + ) ->addField( EntityListField::make('created_at') ->setLabel('Creation date') @@ -63,13 +68,6 @@ public function getInstanceCommands(): ?array ]; } - protected function getFilters(): ?array - { - return [ - HiddenFilter::make('user_id'), - ]; - } - public function delete(mixed $id): void { $this->currentUser()->passKeys()->findOrFail($id)->delete(); @@ -77,18 +75,19 @@ public function delete(mixed $id): void public function getListData(): array|Arrayable { - return $this->transform( - $this->currentUser()->passkeys - ); + return $this + ->setCustomTransformer('usage', function ($value, Model $passkey) { + return $passkey->getKey() == request()->cookie('sharp_last_used_passkey') + ? 'Used in this browser' + : null; + }) + ->transform( + $this->currentUser()->passkeys + ); } protected function currentUser(): Authenticatable&HasPasskeys { - // disabling for now (security concerns) - // if($this->queryParams->filterFor('user_id')) { - // return Config::getAuthenticatableModel()::findOrFail($this->queryParams->filterFor('user_id')); - // } - /** @var Authenticatable&HasPasskeys $user */ $user = auth()->user(); diff --git a/src/Auth/Passkeys/PasskeyEventSubscriber.php b/src/Auth/Passkeys/PasskeyEventSubscriber.php new file mode 100644 index 000000000..9ee0c4943 --- /dev/null +++ b/src/Auth/Passkeys/PasskeyEventSubscriber.php @@ -0,0 +1,17 @@ +listen(PasskeyUsedToAuthenticateEvent::class, function (PasskeyUsedToAuthenticateEvent $event) { + Cookie::queue('sharp_last_used_passkey', $event->passkey->id, 576000); + }); + } +} diff --git a/src/Config/SharpConfigBuilder.php b/src/Config/SharpConfigBuilder.php index 69d2e69e5..ca76f398c 100644 --- a/src/Config/SharpConfigBuilder.php +++ b/src/Config/SharpConfigBuilder.php @@ -5,7 +5,7 @@ use Closure; use Code16\Sharp\Auth\Impersonate\SharpDefaultEloquentImpersonationHandler; use Code16\Sharp\Auth\Impersonate\SharpImpersonationHandler; -use Code16\Sharp\Auth\Passkeys\PasskeyEntity; +use Code16\Sharp\Auth\Passkeys\Entity\PasskeyEntity; use Code16\Sharp\Auth\TwoFactor\Sharp2faHandler; use Code16\Sharp\Exceptions\SharpInvalidConfigException; use Code16\Sharp\Exceptions\SharpInvalidEntityKeyException; diff --git a/src/SharpInternalServiceProvider.php b/src/SharpInternalServiceProvider.php index c660303b3..adba0b1ad 100644 --- a/src/SharpInternalServiceProvider.php +++ b/src/SharpInternalServiceProvider.php @@ -3,6 +3,7 @@ namespace Code16\Sharp; use Code16\Sharp\Auth\Impersonate\SharpImpersonationHandler; +use Code16\Sharp\Auth\Passkeys\PasskeyEventSubscriber; use Code16\Sharp\Auth\SharpAuthorizationManager; use Code16\Sharp\Auth\TwoFactor\Engines\GoogleTotpEngine; use Code16\Sharp\Auth\TwoFactor\Engines\Sharp2faTotpEngine; @@ -96,6 +97,8 @@ public function boot() } $this->configureOctane(); + + Event::subscribe(PasskeyEventSubscriber::class); } public function register() From dccc645f7947641d3c6d31419b8a28bfe65b0530 Mon Sep 17 00:00:00 2001 From: antoine Date: Thu, 26 Mar 2026 19:55:45 +0100 Subject: [PATCH 4/9] wip passkeys --- composer.json | 8 + resources/lang/en/auth.php | 19 + resources/lang/fr/auth.php | 19 + .../Commands/UpdatePasskeyNameCommand.php | 4 +- src/Auth/Passkeys/Entity/PasskeyEntity.php | 6 +- src/Auth/Passkeys/Entity/PasskeyList.php | 24 +- tests/Http/Auth/PasskeysTest.php | 428 +++++++++++++++--- tests/TestCase.php | 6 +- 8 files changed, 431 insertions(+), 83 deletions(-) diff --git a/composer.json b/composer.json index f0218d100..61c89af62 100644 --- a/composer.json +++ b/composer.json @@ -47,6 +47,14 @@ "spatie/laravel-typescript-transformer": "^2.3", "spatie/typescript-transformer": "^2.2" }, + "suggest": { + "spatie/laravel-passkeys": "Allows you to use passkey authentication (^1.0 is required).", + "pragmarx/google2fa-laravel": "Allows you to use 2FA default TOTP command", + "bacon/bacon-qr-code": "Allows you to use QR codes in default TOTP command" + }, + "conflict": { + "spatie/laravel-passkeys": "<1.0 || >=2.0" + }, "autoload": { "files": [ "src/sharp_helper.php" diff --git a/resources/lang/en/auth.php b/resources/lang/en/auth.php index aff0899c3..4352329f9 100644 --- a/resources/lang/en/auth.php +++ b/resources/lang/en/auth.php @@ -28,6 +28,25 @@ ], ], ], + 'passkeys' => [ + 'entity_label' => 'Passkey', + 'commands' => [ + 'rename' => [ + 'command_label' => 'Rename passkey', + 'name_field_label' => 'Name', + ], + 'add' => [ + 'command_label' => 'Add a passkey', + ], + ], + 'fields' => [ + 'name' => 'Name', + 'usage' => 'Usage', + 'created_at' => 'Creation date', + 'last_used_at' => 'Last used at', + ], + 'used_in_this_browser' => 'Used in this browser', + ], 'password_change' => [ 'command' => [ 'label' => 'Change password...', diff --git a/resources/lang/fr/auth.php b/resources/lang/fr/auth.php index 7c1e7b02e..1ba25fdf2 100644 --- a/resources/lang/fr/auth.php +++ b/resources/lang/fr/auth.php @@ -28,6 +28,25 @@ ], ], ], + 'passkeys' => [ + 'entity_label' => 'Clé d’accès', + 'commands' => [ + 'rename' => [ + 'command_label' => 'Renommer la clé d’accès', + 'name_field_label' => 'Nom', + ], + 'add' => [ + 'command_label' => 'Ajouter une clé d’accès', + ], + ], + 'fields' => [ + 'name' => 'Nom', + 'usage' => 'Utilisation', + 'created_at' => 'Date de création', + 'last_used_at' => 'Dernière utilisation', + ], + 'used_in_this_browser' => 'Utilisée dans ce navigateur', + ], 'password_change' => [ 'command' => [ 'label' => 'Modifier le mot de passe...', diff --git a/src/Auth/Passkeys/Commands/UpdatePasskeyNameCommand.php b/src/Auth/Passkeys/Commands/UpdatePasskeyNameCommand.php index 6ef0c4ee6..1b83d5ddb 100644 --- a/src/Auth/Passkeys/Commands/UpdatePasskeyNameCommand.php +++ b/src/Auth/Passkeys/Commands/UpdatePasskeyNameCommand.php @@ -11,14 +11,14 @@ class UpdatePasskeyNameCommand extends InstanceCommand { public function label(): string { - return 'Rename'; + return trans('sharp::auth.passkeys.commands.rename.command_label'); } public function buildFormFields(FieldsContainer $formFields): void { $formFields->addField( SharpFormTextField::make('name') - ->setLabel('Name') + ->setLabel(trans('sharp::auth.passkeys.commands.rename.name_field_label')) ); } diff --git a/src/Auth/Passkeys/Entity/PasskeyEntity.php b/src/Auth/Passkeys/Entity/PasskeyEntity.php index 248314b83..49a67fc3d 100644 --- a/src/Auth/Passkeys/Entity/PasskeyEntity.php +++ b/src/Auth/Passkeys/Entity/PasskeyEntity.php @@ -7,6 +7,10 @@ class PasskeyEntity extends SharpEntity { protected ?string $list = PasskeyList::class; - protected string $label = 'Passkeys'; protected array $prohibitedActions = ['create', 'update']; + + protected function getLabel(): string + { + return trans('sharp::auth.passkeys.entity_label'); + } } diff --git a/src/Auth/Passkeys/Entity/PasskeyList.php b/src/Auth/Passkeys/Entity/PasskeyList.php index 1aca9ab53..aca2dfb6b 100644 --- a/src/Auth/Passkeys/Entity/PasskeyList.php +++ b/src/Auth/Passkeys/Entity/PasskeyList.php @@ -20,19 +20,19 @@ public function buildList(EntityListFieldsContainer $fields): void $fields ->addField( EntityListField::make('name') - ->setLabel('Name') + ->setLabel(trans('sharp::auth.passkeys.fields.name')) ) ->addField( EntityListBadgeField::make('usage') - ->setLabel('Usage') + ->setLabel(trans('sharp::auth.passkeys.fields.usage')) ) ->addField( - EntityListField::make('created_at') - ->setLabel('Creation date') + EntityListField::make('last_used_at') + ->setLabel(trans('sharp::auth.passkeys.fields.last_used_at')) ) ->addField( - EntityListField::make('last_used_at') - ->setLabel('Last used at') + EntityListField::make('created_at') + ->setLabel(trans('sharp::auth.passkeys.fields.created_at')) ); } @@ -48,7 +48,7 @@ public function getEntityCommands(): ?array { public function label(): string { - return 'Add a passkey'; + return trans('sharp::auth.passkeys.commands.add.command_label'); } public function execute(array $data = []): array @@ -78,11 +78,17 @@ public function getListData(): array|Arrayable return $this ->setCustomTransformer('usage', function ($value, Model $passkey) { return $passkey->getKey() == request()->cookie('sharp_last_used_passkey') - ? 'Used in this browser' + ? trans('sharp::auth.passkeys.used_in_this_browser') : null; }) + ->setCustomTransformer('last_used_at', function ($value, Model $passkey) { + return $passkey->last_used_at?->diffForHumans(); + }) + ->setCustomTransformer('created_at', function ($value, Model $passkey) { + return $passkey->created_at?->isoFormat('LLL'); + }) ->transform( - $this->currentUser()->passkeys + $this->currentUser()->passkeys()->orderByDesc('created_at')->get() ); } diff --git a/tests/Http/Auth/PasskeysTest.php b/tests/Http/Auth/PasskeysTest.php index a3842b453..4c102b8af 100644 --- a/tests/Http/Auth/PasskeysTest.php +++ b/tests/Http/Auth/PasskeysTest.php @@ -2,104 +2,394 @@ namespace Code16\Sharp\Tests\Http\Auth; -use Code16\Sharp\Http\Controllers\Auth\Passkeys\PasskeyController; -use Code16\Sharp\Http\Controllers\Auth\Passkeys\PasskeySkipPromptController; +use Code16\Sharp\Auth\Passkeys\Commands\UpdatePasskeyNameCommand; +use Code16\Sharp\Auth\Passkeys\Entity\PasskeyEntity; +use Code16\Sharp\Auth\Passkeys\Entity\PasskeyList; +use Code16\Sharp\Auth\Passkeys\PasskeyEventSubscriber; use Code16\Sharp\Tests\Fixtures\Entities\PersonEntity; -use Code16\Sharp\Tests\Fixtures\TestAuthGuard; use Code16\Sharp\Tests\Fixtures\User; -use Illuminate\Database\Eloquent\Collection; -use Illuminate\Support\Facades\Route; -use Mockery; +use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Events\Dispatcher; +use Illuminate\Foundation\Testing\LazilyRefreshDatabase; +use Illuminate\Support\Facades\Cookie; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; +use Illuminate\Validation\ValidationException; +use Spatie\LaravelPasskeys\Actions\GeneratePasskeyRegisterOptionsAction; +use Spatie\LaravelPasskeys\Actions\StorePasskeyAction; +use Spatie\LaravelPasskeys\Events\PasskeyUsedToAuthenticateEvent; +use Spatie\LaravelPasskeys\Http\Requests\AuthenticateUsingPasskeysRequest; +use Spatie\LaravelPasskeys\Models\Concerns\HasPasskeys; +use Spatie\LaravelPasskeys\Models\Concerns\InteractsWithPasskeys; +use Spatie\LaravelPasskeys\Models\Passkey; +use Webauthn\PublicKeyCredentialCreationOptions; + +uses(LazilyRefreshDatabase::class); beforeEach(function () { - auth()->extend('sharp', fn () => new TestAuthGuard()); - config()->set('auth.guards.sharp', ['driver' => 'sharp', 'provider' => 'users']); + Schema::create('users', function (Blueprint $table) { + $table->increments('id'); + $table->string('name')->nullable(); + $table->string('email'); + $table->string('password')->nullable(); + $table->string('remember_token')->nullable(); + $table->timestamps(); + }); + + Schema::create('passkeys', function (Blueprint $table) { + $table->id(); + $table->unsignedInteger('authenticatable_id'); + $table->text('name'); + $table->text('credential_id'); + $table->json('data'); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + }); + + config()->set('auth.providers.users.model', PasskeyTestUser::class); + config()->set('passkeys.models.authenticatable', PasskeyTestUser::class); + config()->set('passkeys.models.passkey', TestPasskey::class); sharp()->config() ->declareEntity(PersonEntity::class) - ->setAuthCustomGuard('sharp') ->enablePasskeys(); }); -it('sets passkeys.redirect session key in login show', function () { - $this->get(route('code16.sharp.login')) +function createPasskeyTestUser(array $attributes = []): PasskeyTestUser +{ + return PasskeyTestUser::create(array_merge([ + 'email' => 'test@example.org', + 'name' => 'Test', + ], $attributes)); +} + +function createPasskey(PasskeyTestUser $user, array $attributes = []): TestPasskey +{ + $attrs = array_merge([ + 'name' => 'My Passkey', + 'credential_id' => 'test-credential-'.uniqid(), + 'last_used_at' => null, + ], $attributes); + + $id = DB::table('passkeys')->insertGetId([ + 'authenticatable_id' => $user->id, + 'name' => $attrs['name'], + 'credential_id' => $attrs['credential_id'], + 'data' => json_encode(['test' => true]), + 'last_used_at' => $attrs['last_used_at'], + 'created_at' => $attrs['created_at'] ?? now(), + 'updated_at' => now(), + ]); + + return TestPasskey::find($id); +} + +function loginPasskeyUser(?PasskeyTestUser $user = null) +{ + test()->actingAs( + $user ?: createPasskeyTestUser(), + sharp()->config()->get('auth.guard') ?: 'web' + ); +} + +// --- PasskeyEntity tests --- + +it('has correct prohibited actions', function () { + $entity = new PasskeyEntity(); + $reflection = new \ReflectionProperty($entity, 'prohibitedActions'); + + expect($reflection->getValue($entity))->toContain('create', 'update'); +}); + +// --- PasskeyList data --- + +it('returns list data for authenticated user', function () { + $user = createPasskeyTestUser(); + createPasskey($user, ['name' => 'My Passkey', 'last_used_at' => now()->subDay()]); + loginPasskeyUser($user); + + $list = app(PasskeyList::class); + $data = $list->getListData(); + + expect($data)->toHaveCount(1); + expect($data[0]['name'])->toBe('My Passkey'); +}); + +it('does not return passkeys of other users', function () { + $user = createPasskeyTestUser(); + $otherUser = createPasskeyTestUser(['email' => 'other@example.org']); + createPasskey($otherUser, ['name' => 'Other Passkey']); + loginPasskeyUser($user); + + $list = app(PasskeyList::class); + $data = $list->getListData(); + + expect($data)->toHaveCount(0); +}); + +it('transforms last_used_at to human readable format', function () { + $user = createPasskeyTestUser(); + createPasskey($user, ['last_used_at' => now()->subHour()]); + loginPasskeyUser($user); + + $list = app(PasskeyList::class); + $data = $list->getListData(); + + expect($data[0]['last_used_at'])->toBeString()->not->toBeEmpty(); +}); + +// --- PasskeyList delete --- + +it('deletes a passkey for the authenticated user', function () { + $user = createPasskeyTestUser(); + $passkey = createPasskey($user); + loginPasskeyUser($user); + + $list = app(PasskeyList::class); + $list->delete($passkey->id); + + expect($user->passkeys()->count())->toBe(0); +}); + +it('cannot delete another user passkey', function () { + $user = createPasskeyTestUser(); + $otherUser = createPasskeyTestUser(['email' => 'other@example.org']); + $passkey = createPasskey($otherUser); + loginPasskeyUser($user); + + $list = app(PasskeyList::class); + + expect(fn () => $list->delete($passkey->id)) + ->toThrow(ModelNotFoundException::class); +}); + +// --- UpdatePasskeyNameCommand --- + +it('renames a passkey', function () { + $user = createPasskeyTestUser(); + $passkey = createPasskey($user); + loginPasskeyUser($user); + + $command = app(UpdatePasskeyNameCommand::class); + $command->execute($passkey->id, ['name' => 'Renamed Passkey']); + + expect($passkey->fresh()->name)->toBe('Renamed Passkey'); +}); + +it('validates name is required when renaming', function () { + $user = createPasskeyTestUser(); + $passkey = createPasskey($user); + loginPasskeyUser($user); + + $command = app(UpdatePasskeyNameCommand::class); + + expect(fn () => $command->execute($passkey->id, ['name' => ''])) + ->toThrow(ValidationException::class); +}); + +// --- PasskeyEventSubscriber --- + +it('queues a cookie when passkey is used to authenticate', function () { + // here we expects the SharpInternalServiceProvider is booted + $user = createPasskeyTestUser(); + $passkey = createPasskey($user); + + $request = \Mockery::mock(AuthenticateUsingPasskeysRequest::class); + $event = new PasskeyUsedToAuthenticateEvent($passkey, $request); + app(Dispatcher::class)->dispatch($event); + + $queued = Cookie::getQueuedCookies(); + $names = array_map(fn ($c) => $c->getName(), $queued); + expect($names)->toContain('sharp_last_used_passkey'); +}); + +// --- Usage badge --- + +it('shows usage badge when passkey matches cookie', function () { + $user = createPasskeyTestUser(); + $passkey = createPasskey($user); + loginPasskeyUser($user); + + request()->cookies->set('sharp_last_used_passkey', (string) $passkey->id); + + $list = app(PasskeyList::class); + $data = $list->getListData(); + + expect($data[0]['usage'])->not->toBeNull(); +}); + +it('does not show usage badge when passkey does not match cookie', function () { + $user = createPasskeyTestUser(); + createPasskey($user); + loginPasskeyUser($user); + + request()->cookies->set('sharp_last_used_passkey', '99999'); + + $list = app(PasskeyList::class); + $data = $list->getListData(); + + expect($data[0]['usage'])->toBeNull(); +}); + +// --- PasskeyController tests --- + +it('renders the passkey create page', function () { + loginPasskeyUser(); + + $this->get(route('code16.sharp.passkeys.create')) ->assertOk(); +}); + +it('renders the passkey create page with prompt parameter', function () { + loginPasskeyUser(); - expect(session()->get('passkeys.redirect'))->toBe(route('code16.sharp.home')); + $this->get(route('code16.sharp.passkeys.create', ['prompt' => 1])) + ->assertOk(); }); -it('redirects to passkey creation after login if prompt is enabled, supported and no passkeys', function () { - Route::get('/passkeys/create', [PasskeyController::class, 'create']) - ->name('code16.sharp.passkeys.create'); +it('requires authentication to access passkey create', function () { + $this->get(route('code16.sharp.passkeys.create')) + ->assertRedirect(); +}); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('passkeys')->andReturn( - Mockery::mock(Collection::class)->shouldReceive('count')->andReturn(0)->getMock() - ); +it('validates name on passkey validate endpoint', function () { + loginPasskeyUser(); - $this->actingAs($user, 'sharp') - ->post(route('code16.sharp.login.post'), [ - 'login' => 'test@example.org', - 'password' => 'password', - 'supports_passkeys' => true, - ]) - ->assertRedirect('/passkeys/create?prompt=1'); + $this->postJson(route('code16.sharp.passkeys.validate'), ['name' => '']) + ->assertJsonValidationErrors('name'); + + $this->postJson(route('code16.sharp.passkeys.validate'), ['name' => str_repeat('a', 256)]) + ->assertJsonValidationErrors('name'); }); -it('does not redirect to passkey creation if user already has passkeys', function () { - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('passkeys')->andReturn( - Mockery::mock(Collection::class)->shouldReceive('count')->andReturn(1)->getMock() - ); +it('returns passkey options on successful validate', function () { + $user = createPasskeyTestUser(); + loginPasskeyUser($user); - $this->actingAs($user, 'sharp') - ->post(route('code16.sharp.login.post'), [ - 'login' => 'test@example.org', - 'password' => 'password', - 'supports_passkeys' => true, - ]) - ->assertRedirect(route('code16.sharp.home')); + // Configure a fake action that returns a JSON string + config()->set('passkeys.actions.generate_passkey_register_options', FakeGeneratePasskeyRegisterOptionsAction::class); + + $this->postJson(route('code16.sharp.passkeys.validate'), ['name' => 'My Key']) + ->assertOk() + ->assertJsonStructure(['passkeyOptions']); }); -it('does not redirect to passkey creation if browser does not support passkeys', function () { - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('passkeys')->andReturn( - Mockery::mock(Collection::class)->shouldReceive('count')->andReturn(0)->getMock() - ); +it('store endpoint requires authentication', function () { + $this->post(route('code16.sharp.passkeys.store')) + ->assertRedirect(); +}); - $this->actingAs($user, 'sharp') - ->post(route('code16.sharp.login.post'), [ - 'login' => 'test@example.org', - 'password' => 'password', - 'supports_passkeys' => false, - ]) - ->assertRedirect(route('code16.sharp.home')); +it('store endpoint catches action errors and throws validation exception', function () { + $user = createPasskeyTestUser(); + loginPasskeyUser($user); + + // Put fake options in session as the controller expects them + session()->put('passkey-registration-options', '{"fake":"options"}'); + + $this->postJson(route('code16.sharp.passkeys.store'), [ + 'passkey' => 'invalid-passkey-data', + 'name' => 'My Key', + ]) + ->assertStatus(422) + ->assertJsonValidationErrors('name'); }); -it('does not redirect to passkey creation if skip cookie is present', function () { - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('passkeys')->andReturn( - Mockery::mock(Collection::class)->shouldReceive('count')->andReturn(0)->getMock() - ); +class FakeStorePasskeyAction extends StorePasskeyAction +{ + public static $mock; + public function execute($authenticatable, $passkeyJson, $passkeyOptionsJson, $hostName, $additionalProperties = []): Passkey + { + return static::$mock->execute($authenticatable, $passkeyJson, $passkeyOptionsJson, $hostName, $additionalProperties); + } +} + +it('store endpoint calls StorePasskeyAction with appropriate arguments', function () { + $user = createPasskeyTestUser(); + loginPasskeyUser($user); + + $passkeyData = '{"id":"some-id","rawId":"some-raw-id","type":"public-key","response":{"attestationObject":"some-attestation","clientDataJSON":"some-client-data"}}'; + $passkeyOptions = '{"challenge":"some-challenge"}'; + $passkeyName = 'My New Passkey'; + + session()->put('passkey-registration-options', $passkeyOptions); + + $mockAction = \Mockery::mock(Spatie\LaravelPasskeys\Actions\StorePasskeyAction::class); + $mockAction->shouldReceive('execute') + ->once() + ->withArgs(function ($authenticatable, $passkeyJson, $optionsJson, $host, $additionalProperties) use ($user, $passkeyData, $passkeyOptions, $passkeyName) { + return $authenticatable->is($user) + && $passkeyJson === $passkeyData + && $optionsJson === $passkeyOptions + && $host === request()->getHost() + && $additionalProperties === ['name' => $passkeyName]; + }) + ->andReturn(new TestPasskey()); - $this->withCookie('sharp_skip_passkey_prompt', '1') - ->actingAs($user, 'sharp') - ->post(route('code16.sharp.login.post'), [ - 'login' => 'test@example.org', - 'password' => 'password', - 'supports_passkeys' => true, - ]) + FakeStorePasskeyAction::$mock = $mockAction; + config()->set('passkeys.actions.store_passkey', FakeStorePasskeyAction::class); + + $this->postJson(route('code16.sharp.passkeys.store'), [ + 'passkey' => $passkeyData, + 'name' => $passkeyName, + ]) ->assertRedirect(route('code16.sharp.home')); + + $mockAction->shouldHaveReceived('execute'); }); -it('sets skip cookie in PasskeySkipPromptController', function () { - Route::post('/passkeys/skip-prompt', PasskeySkipPromptController::class) - ->name('code16.sharp.passkeys.skip-prompt'); +// --- PasskeySkipPromptController tests --- - $user = new User(['email' => 'test@example.org']); +it('skip prompt redirects to home with cookie', function () { + loginPasskeyUser(); - $this->actingAs($user, 'sharp') - ->post('/passkeys/skip-prompt') + $this->post(route('code16.sharp.passkeys.skip-prompt')) ->assertRedirect(route('code16.sharp.home')) - ->assertCookie('sharp_skip_passkey_prompt', true); + ->assertCookie('sharp_skip_passkey_prompt'); }); + +// --- Fixtures --- + +class TestPasskey extends Passkey +{ + protected $table = 'passkeys'; + + public function data(): Attribute + { + return new Attribute( + get: fn ($value) => $value, + set: fn ($value) => ['data' => is_string($value) ? $value : json_encode($value)], + ); + } +} + +class FakeGeneratePasskeyRegisterOptionsAction extends GeneratePasskeyRegisterOptionsAction +{ + public function execute(HasPasskeys $authenticatable, bool $asJson = true): string|PublicKeyCredentialCreationOptions + { + return '{"challenge":"fake-challenge","rp":{"name":"test"}}'; + } +} + +class PasskeyTestUser extends User implements HasPasskeys +{ + use InteractsWithPasskeys; + + protected $table = 'users'; + + public function getPasskeyName(): string + { + return $this->email; + } + + public function getPasskeyId(): string + { + return (string) $this->id; + } + + public function getPasskeyDisplayName(): string + { + return $this->name ?? $this->email; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index bafa29a4f..4aff2c875 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,6 +3,7 @@ namespace Code16\Sharp\Tests; use BladeUI\Icons\BladeIconsServiceProvider; +use BladeUI\Icons\Factory; use Code16\ContentRenderer\ContentRendererServiceProvider; use Code16\Sharp\SharpInternalServiceProvider; use Illuminate\Testing\Fluent\AssertableJson; @@ -52,7 +53,7 @@ public function getEnvironmentSetUp($app) { config()->set('database.default', 'testing'); - $app->make(\BladeUI\Icons\Factory::class)->add('testicon', [ + $app->make(Factory::class)->add('testicon', [ 'path' => __DIR__.'/Fixtures/resources/svg', 'prefix' => 'testicon', ]); @@ -62,6 +63,7 @@ public function getEnvironmentSetUp($app) // We have to set these two because otherwise corresponding routes won't be loaded at all sharp()->config() ->enableForgottenPassword() - ->enableImpersonation(); + ->enableImpersonation() + ->enablePasskeys(); } } From 7160260abc89b821b81d44b97ab99aebc2128a96 Mon Sep 17 00:00:00 2001 From: antoine Date: Fri, 27 Mar 2026 13:47:58 +0100 Subject: [PATCH 5/9] wip passkeys --- demo/app/Models/Passkey.php | 27 ----------- demo/app/Sharp/Profile/ProfileSingleShow.php | 1 + demo/composer.lock | 12 ++--- demo/config/passkeys.php | 49 -------------------- resources/js/Pages/Auth/Login.vue | 10 +++- resources/js/Pages/Auth/Passkeys/Create.vue | 14 +++--- resources/lang/en/pages/auth/login.php | 1 + resources/lang/en/pages/auth/passkeys.php | 3 +- resources/lang/fr/pages/auth/passkeys.php | 4 +- 9 files changed, 29 insertions(+), 92 deletions(-) delete mode 100644 demo/app/Models/Passkey.php delete mode 100644 demo/config/passkeys.php diff --git a/demo/app/Models/Passkey.php b/demo/app/Models/Passkey.php deleted file mode 100644 index bf69d020d..000000000 --- a/demo/app/Models/Passkey.php +++ /dev/null @@ -1,27 +0,0 @@ - $serializer->fromJson( - $value, - CredentialRecord::class, - ), - set: fn (CredentialRecord $value) => [ - 'credential_id' => self::encodeCredentialId($value->publicKeyCredentialId), - 'data' => $serializer->toJson($value), - ], - ); - } -} diff --git a/demo/app/Sharp/Profile/ProfileSingleShow.php b/demo/app/Sharp/Profile/ProfileSingleShow.php index d76e6ade5..e4b7fc697 100644 --- a/demo/app/Sharp/Profile/ProfileSingleShow.php +++ b/demo/app/Sharp/Profile/ProfileSingleShow.php @@ -30,6 +30,7 @@ protected function buildShowFields(FieldsContainer $showFields): void ) ->when(config('demo.enable_passkeys'), fn () => $showFields->addField( SharpShowEntityListField::make(PasskeyEntity::class) + ->setLabel('Passkeys') )); } diff --git a/demo/composer.lock b/demo/composer.lock index c08eb5c64..5994d0950 100644 --- a/demo/composer.lock +++ b/demo/composer.lock @@ -4468,16 +4468,16 @@ }, { "name": "spatie/laravel-passkeys", - "version": "1.6.4", + "version": "1.6.5", "source": { "type": "git", "url": "https://github.com/spatie/laravel-passkeys.git", - "reference": "7c9825a193107b951e2016f371a73d4b634986e0" + "reference": "108bc6b163034b2a19e22fdcf7a0a4bac5090585" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-passkeys/zipball/7c9825a193107b951e2016f371a73d4b634986e0", - "reference": "7c9825a193107b951e2016f371a73d4b634986e0", + "url": "https://api.github.com/repos/spatie/laravel-passkeys/zipball/108bc6b163034b2a19e22fdcf7a0a4bac5090585", + "reference": "108bc6b163034b2a19e22fdcf7a0a4bac5090585", "shasum": "" }, "require": { @@ -4534,7 +4534,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-passkeys/issues", - "source": "https://github.com/spatie/laravel-passkeys/tree/1.6.4" + "source": "https://github.com/spatie/laravel-passkeys/tree/1.6.5" }, "funding": [ { @@ -4542,7 +4542,7 @@ "type": "github" } ], - "time": "2026-03-25T08:24:34+00:00" + "time": "2026-03-27T10:29:37+00:00" }, { "name": "spatie/laravel-translatable", diff --git a/demo/config/passkeys.php b/demo/config/passkeys.php deleted file mode 100644 index 9d75064e2..000000000 --- a/demo/config/passkeys.php +++ /dev/null @@ -1,49 +0,0 @@ - '/dashboard', - - /* - * These class are responsible for performing core tasks regarding passkeys. - * You can customize them by creating a class that extends the default, and - * by specifying your custom class name here. - */ - 'actions' => [ - 'generate_passkey_register_options' => GeneratePasskeyRegisterOptionsAction::class, - 'store_passkey' => StorePasskeyAction::class, - 'generate_passkey_authentication_options' => GeneratePasskeyAuthenticationOptionsAction::class, - 'find_passkey' => FindPasskeyToAuthenticateAction::class, - 'configure_ceremony_step_manager_factory' => ConfigureCeremonyStepManagerFactoryAction::class, - ], - - /* - * These properties will be used to generate the passkey. - */ - 'relying_party' => [ - 'name' => config('app.name'), - 'id' => parse_url(config('app.url'), PHP_URL_HOST), - 'icon' => null, - ], - - /* - * The models used by the package. - * - * You can override this by specifying your own models - */ - 'models' => [ - 'passkey' => Passkey::class, - 'authenticatable' => env('AUTH_MODEL', User::class), - ], -]; diff --git a/resources/js/Pages/Auth/Login.vue b/resources/js/Pages/Auth/Login.vue index cadff4123..3fa88e9b3 100644 --- a/resources/js/Pages/Auth/Login.vue +++ b/resources/js/Pages/Auth/Login.vue @@ -15,13 +15,14 @@ import { FormItem } from "@/components/ui/form"; import { Check } from "lucide-vue-next"; import TemplateRenderer from "@/components/TemplateRenderer.vue"; - import { onMounted, ref } from "vue"; + import { onMounted } from "vue"; import { startAuthentication, browserSupportsWebAuthn, browserSupportsWebAuthnAutofill } from "@simplewebauthn/browser"; import { api } from "@/api/api"; + import { Separator } from "@/components/ui/separator"; const props = defineProps<{ loginIsEmail: boolean, @@ -149,6 +150,13 @@ {{ __('sharp::pages/auth/login.button') }}