From 7bed78c81e39aa26840ff48bd305db0f6202a1b7 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Tue, 13 Oct 2026 04:01:40 +0000 Subject: [PATCH 1/3] feat(auth): add passkey sign-in Enable Fortify passkeys (laravel/passkeys) with the @laravel/passkeys client. Users can sign in with a passkey, confirm sensitive actions with one, and add, rename, and delete passkeys on the Profile page. - Resolve the relying party for each request from the instance URL, then APP_URL, then localhost. Passkey routes return 404 on other hosts. - Add the is_passkey_enabled instance setting (Settings > Advanced). - Use a hex user handle (laravel/passkeys-server#41). - Rate limit the passkey routes by client IP. - Record audit events for registered, verified, and deleted passkeys. --- app/Http/Middleware/ConfigurePasskeys.php | 28 ++++ app/Livewire/Profile/Passkeys.php | 70 +++++++++ app/Livewire/Settings/Advanced.php | 6 + app/Models/InstanceSettings.php | 2 + app/Models/User.php | 18 ++- app/Providers/EventServiceProvider.php | 12 ++ app/Providers/FortifyServiceProvider.php | 7 +- app/Providers/RouteServiceProvider.php | 4 + app/Support/PasskeyRelyingParty.php | 49 +++++++ bootstrap/helpers/subscriptions.php | 5 + config/fortify.php | 23 ++- ...026_10_12_164601_create_passkeys_table.php | 34 +++++ ...key_enabled_to_instance_settings_table.php | 28 ++++ package-lock.json | 37 ++++- package.json | 1 + resources/js/passkeys.js | 22 +++ .../views/auth/confirm-password.blade.php | 5 + resources/views/auth/login.blade.php | 10 +- .../components/auth/passkey-verify.blade.php | 55 +++++++ .../views/livewire/profile/index.blade.php | 2 + .../views/livewire/profile/passkeys.blade.php | 104 +++++++++++++ .../livewire/settings/advanced.blade.php | 6 + .../views/livewire/settings/index.blade.php | 2 +- routes/web.php | 3 + tests/Feature/Auth/PasskeyAccessTest.php | 78 ++++++++++ .../Auth/PasskeyInstanceToggleTest.php | 39 +++++ .../Feature/Auth/PasskeyRelyingPartyTest.php | 100 +++++++++++++ tests/Feature/Auth/PasskeySetupTest.php | 42 ++++++ tests/Feature/ProfilePasskeysTest.php | 137 ++++++++++++++++++ vite.config.js | 1 + 30 files changed, 922 insertions(+), 8 deletions(-) create mode 100644 app/Http/Middleware/ConfigurePasskeys.php create mode 100644 app/Livewire/Profile/Passkeys.php create mode 100644 app/Support/PasskeyRelyingParty.php create mode 100644 database/migrations/2026_10_12_164601_create_passkeys_table.php create mode 100644 database/migrations/2026_10_12_164603_add_is_passkey_enabled_to_instance_settings_table.php create mode 100644 resources/js/passkeys.js create mode 100644 resources/views/components/auth/passkey-verify.blade.php create mode 100644 resources/views/livewire/profile/passkeys.blade.php create mode 100644 tests/Feature/Auth/PasskeyAccessTest.php create mode 100644 tests/Feature/Auth/PasskeyInstanceToggleTest.php create mode 100644 tests/Feature/Auth/PasskeyRelyingPartyTest.php create mode 100644 tests/Feature/Auth/PasskeySetupTest.php create mode 100644 tests/Feature/ProfilePasskeysTest.php diff --git a/app/Http/Middleware/ConfigurePasskeys.php b/app/Http/Middleware/ConfigurePasskeys.php new file mode 100644 index 0000000000..e5465c28a4 --- /dev/null +++ b/app/Http/Middleware/ConfigurePasskeys.php @@ -0,0 +1,28 @@ +routeIs('passkey.*')) { + return $next($request); + } + + $relyingParty = instanceSettings()->is_passkey_enabled ? PasskeyRelyingParty::resolve($request) : null; + abort_if($relyingParty === null, 404); + + config([ + 'passkeys.relying_party_id' => $relyingParty['id'], + 'passkeys.allowed_origins' => $relyingParty['origins'], + ]); + + return $next($request); + } +} diff --git a/app/Livewire/Profile/Passkeys.php b/app/Livewire/Profile/Passkeys.php new file mode 100644 index 0000000000..2620e92d90 --- /dev/null +++ b/app/Livewire/Profile/Passkeys.php @@ -0,0 +1,70 @@ +show_form = session('status') === 'passkey-add'; + } + + public function registered(): void + { + $this->show_form = false; + $this->dispatch('success', 'Passkey added.'); + } + + public function rename(int $id, string $name): bool + { + abort_unless(instanceSettings()->is_passkey_enabled, 404); + + $passkey = Auth::user()->passkeys()->findOrFail($id); + + $validated = Validator::make( + ['name' => trim($name)], + ['name' => ['required', 'string', 'max:255']], + )->validate(); + + $passkey->update(['name' => $validated['name']]); + $this->dispatch('success', 'Passkey renamed.'); + + return true; + } + + public function delete(int $id, mixed $password = null): mixed + { + abort_unless(instanceSettings()->is_passkey_enabled, 404); + + $passkey = Auth::user()->passkeys()->findOrFail($id); + + if (! verifyPasswordConfirmation($password, $this)) { + return 'The provided password is incorrect.'; + } + + app(DeletePasskey::class)(Auth::user(), $passkey); + $this->dispatch('success', 'Passkey deleted.'); + + return true; + } + + public function render(): View + { + $enabled = (bool) instanceSettings()->is_passkey_enabled; + + return view('livewire.profile.passkeys', [ + 'enabled' => $enabled, + 'available' => $enabled && PasskeyRelyingParty::isAvailable(), + 'passkeys' => $enabled ? Auth::user()->passkeys()->latest()->get() : collect(), + ]); + } +} diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index a3855eac3b..28cbc971d2 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -41,6 +41,9 @@ class Advanced extends Component #[Validate('boolean')] public bool $disable_two_step_confirmation; + #[Validate('boolean')] + public bool $is_passkey_enabled; + #[Validate('boolean')] public bool $is_wire_navigate_enabled; @@ -72,6 +75,7 @@ class Advanced extends Component 'allowed_ips' => ['nullable', 'string', new ValidIpOrCidr], 'is_sponsorship_popup_enabled' => 'boolean', 'disable_two_step_confirmation' => 'boolean', + 'is_passkey_enabled' => 'boolean', 'is_wire_navigate_enabled' => 'boolean', 'is_mcp_server_enabled' => 'boolean', 'webhook_allowed_internal_hosts' => 'nullable|string', @@ -95,6 +99,7 @@ class Advanced extends Component $this->is_dns_validation_enabled = $this->settings->is_dns_validation_enabled; $this->is_api_enabled = $this->settings->is_api_enabled; $this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation; + $this->is_passkey_enabled = $this->settings->is_passkey_enabled ?? true; $this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled; $this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true; $this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false; @@ -216,6 +221,7 @@ class Advanced extends Component $this->settings->allowed_ips = $this->allowed_ips; $this->settings->is_sponsorship_popup_enabled = $this->is_sponsorship_popup_enabled; $this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation; + $this->settings->is_passkey_enabled = $this->is_passkey_enabled; $this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled; $this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled; $this->settings->webhook_allowed_internal_hosts = $webhookAllowedInternalHosts ?? $this->settings->webhook_allowed_internal_hosts ?? []; diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 16a1d7ea30..480beb9088 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -59,6 +59,7 @@ class InstanceSettings extends Model 'avatar_s3_storage_id', 'image_cdn_url', 'is_dashboard_force_https_enabled', + 'is_passkey_enabled', ]; protected $hidden = [ @@ -100,6 +101,7 @@ class InstanceSettings extends Model 'webhook_allowed_internal_hosts' => 'array', 'webhook_allow_localhost' => 'boolean', 'is_dashboard_force_https_enabled' => 'boolean', + 'is_passkey_enabled' => 'boolean', ]; protected static function booted(): void diff --git a/app/Models/User.php b/app/Models/User.php index c5e9f592c3..743871fcfc 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -21,6 +21,8 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\URL; use Illuminate\Support\Str; +use Laravel\Fortify\Contracts\PasskeyUser; +use Laravel\Fortify\PasskeyAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\NewAccessToken; @@ -41,9 +43,9 @@ use OpenApi\Attributes as OA; 'marketing_emails' => ['type' => 'boolean', 'description' => 'The flag to receive marketing emails.'], ], )] -class User extends Authenticatable implements SendsEmail +class User extends Authenticatable implements PasskeyUser, SendsEmail { - use DeletesUserSessions, HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; + use DeletesUserSessions, HasApiTokens, HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable; protected $fillable = [ 'name', @@ -599,6 +601,18 @@ class User extends Authenticatable implements SendsEmail return $this->oauthIdentities()->exists(); } + /** + * Hex handle: iOS Safari corrupts raw binary handles (laravel/passkeys-server#41). + */ + public function getPasskeyUserHandle(): string + { + return hash_hmac( + 'sha256', + $this->getTable().'|'.$this->getKey(), + Config::string('passkeys.user_handle_secret'), + ); + } + /** * Check if the user has a password set. */ diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index e9da72d442..a894b7f3d9 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -14,6 +14,9 @@ use Laravel\Fortify\Events\RecoveryCodesGenerated; use Laravel\Fortify\Events\TwoFactorAuthenticationConfirmed; use Laravel\Fortify\Events\TwoFactorAuthenticationDisabled; use Laravel\Fortify\Events\TwoFactorAuthenticationEnabled; +use Laravel\Passkeys\Events\PasskeyDeleted; +use Laravel\Passkeys\Events\PasskeyRegistered; +use Laravel\Passkeys\Events\PasskeyVerified; use SocialiteProviders\Authentik\AuthentikExtendSocialite; use SocialiteProviders\Azure\AzureExtendSocialite; use SocialiteProviders\Clerk\ClerkExtendSocialite; @@ -72,6 +75,15 @@ class EventServiceProvider extends ServiceProvider Event::listen(RecoveryCodesGenerated::class, function (RecoveryCodesGenerated $event): void { auditLog('auth.user.recovery_codes_regenerated', $this->authContext($event->user)); }); + Event::listen(PasskeyRegistered::class, function (PasskeyRegistered $event): void { + auditLog('auth.user.passkey_registered', $this->authContext($event->user)); + }); + Event::listen(PasskeyVerified::class, function (PasskeyVerified $event): void { + auditLog('auth.user.passkey_verified', $this->authContext($event->user)); + }); + Event::listen(PasskeyDeleted::class, function (PasskeyDeleted $event): void { + auditLog('auth.user.passkey_deleted', $this->authContext($event->user)); + }); } /** diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 9bdc5d7371..d6519aac0e 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -7,6 +7,7 @@ use App\Actions\Fortify\ResetUserPassword; use App\Actions\Fortify\UpdateUserPassword; use App\Models\OauthSetting; use App\Models\User; +use App\Support\PasskeyRelyingParty; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; use Illuminate\Support\ServiceProvider; @@ -68,6 +69,7 @@ class FortifyServiceProvider extends ServiceProvider fn (OauthSetting $oauthSetting) => $oauthSetting->couldBeEnabled() && $oauthSetting->allowsUserCreation() ), 'enabled_oauth_providers' => $enabled_oauth_providers, + 'passkeys_available' => PasskeyRelyingParty::isAvailable(), ]); }); @@ -108,7 +110,10 @@ class FortifyServiceProvider extends ServiceProvider Fortify::updateUserPasswordsUsing(UpdateUserPassword::class); Fortify::confirmPasswordView(function () { - return view('auth.confirm-password'); + return view('auth.confirm-password', [ + 'can_confirm_with_passkey' => PasskeyRelyingParty::isAvailable() + && auth()->user()->hasPasskeysEnabled(), + ]); }); Fortify::twoFactorChallengeView(function () { diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 24cec00b4c..dedac3a095 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -74,6 +74,10 @@ class RouteServiceProvider extends ServiceProvider return Limit::perMinute(5)->by($request->session()->get('login.id')); }); + RateLimiter::for('passkeys', function (Request $request) { + return Limit::perMinute(30)->by('passkeys:'.auth_rate_limit_ip($request)); + }); + RateLimiter::for('forgot-password', function (Request $request) { $limits = [ Limit::perMinutes(10, 3)->by('forgot-password:ip:'.sha1(auth_rate_limit_ip($request))), diff --git a/app/Support/PasskeyRelyingParty.php b/app/Support/PasskeyRelyingParty.php new file mode 100644 index 0000000000..c066feec41 --- /dev/null +++ b/app/Support/PasskeyRelyingParty.php @@ -0,0 +1,49 @@ +}|null + */ + public static function resolve(Request $request): ?array + { + $requestHost = $request->getHost(); + + foreach ([instanceSettings()->fqdn, config('app.url')] as $url) { + $parts = parse_url((string) $url); + $host = strtolower($parts['host'] ?? ''); + + if (strtolower($parts['scheme'] ?? '') !== 'https' || $host !== $requestHost) { + continue; + } + if (filter_var(trim($host, '[]'), FILTER_VALIDATE_IP) !== false) { + continue; + } + + $origins = ['https://'.$host.(isset($parts['port']) ? ':'.$parts['port'] : '')]; + + // The fqdn setting stores no port, so a custom port can only come from the request. + if (! isset($parts['port']) && ! in_array($request->getPort(), [80, 443])) { + $origins[] = 'https://'.$host.':'.$request->getPort(); + } + + return ['id' => $host, 'origins' => $origins]; + } + + if ($requestHost === 'localhost') { + return ['id' => 'localhost', 'origins' => [$request->getSchemeAndHttpHost()]]; + } + + return null; + } + + public static function isAvailable(?Request $request = null): bool + { + return (bool) instanceSettings()->is_passkey_enabled + && self::resolve($request ?? request()) !== null; + } +} diff --git a/bootstrap/helpers/subscriptions.php b/bootstrap/helpers/subscriptions.php index 971afdfb79..afe1631a19 100644 --- a/bootstrap/helpers/subscriptions.php +++ b/bootstrap/helpers/subscriptions.php @@ -85,6 +85,11 @@ function allowedPathsForUnsubscribedAccounts() // Account basics stay available without a paid plan. 'profile', 'profile/appearance', + 'profile/passkeys/confirm', + 'user/passkeys', + 'user/passkeys/options', + 'passkeys/confirm', + 'passkeys/confirm/options', ]; } function allowedPathsForBoardingAccounts() diff --git a/config/fortify.php b/config/fortify.php index 5b7864af01..03b1c70ae0 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -1,5 +1,6 @@ ['web'], + 'middleware' => ['web', ConfigurePasskeys::class], /* |-------------------------------------------------------------------------- @@ -106,6 +107,7 @@ return [ 'login' => 'login', 'two-factor' => 'two-factor', 'forgot-password' => 'forgot-password', + 'passkeys' => 'passkeys', ], /* @@ -121,6 +123,22 @@ return [ 'views' => true, + /* + |-------------------------------------------------------------------------- + | Passkeys + |-------------------------------------------------------------------------- + | + | Defaults only. ConfigurePasskeys sets the relying party for each request. + | + */ + + 'passkeys' => [ + 'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST), + 'allowed_origins' => [config('app.url')], + 'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', config('app.key')), + 'timeout' => 60000, + ], + /* |-------------------------------------------------------------------------- | Features @@ -142,6 +160,9 @@ return [ 'confirmPassword' => true, // 'window' => 0, ]), + Features::passkeys([ + 'confirmPassword' => true, + ]), ], ]; diff --git a/database/migrations/2026_10_12_164601_create_passkeys_table.php b/database/migrations/2026_10_12_164601_create_passkeys_table.php new file mode 100644 index 0000000000..cb884c3988 --- /dev/null +++ b/database/migrations/2026_10_12_164601_create_passkeys_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('credential_id')->unique(); + $table->json('credential'); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + + $table->index('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('passkeys'); + } +}; diff --git a/database/migrations/2026_10_12_164603_add_is_passkey_enabled_to_instance_settings_table.php b/database/migrations/2026_10_12_164603_add_is_passkey_enabled_to_instance_settings_table.php new file mode 100644 index 0000000000..674486416d --- /dev/null +++ b/database/migrations/2026_10_12_164603_add_is_passkey_enabled_to_instance_settings_table.php @@ -0,0 +1,28 @@ +boolean('is_passkey_enabled')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('is_passkey_enabled'); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index 3f21ba9329..f70d8ae9ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "coolify", "dependencies": { + "@laravel/passkeys": "^0.4.0", "@tailwindcss/forms": "0.5.11", "@tailwindcss/typography": "0.5.20", "@tanstack/virtual-core": "3.17.11", @@ -87,6 +88,34 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@laravel/passkeys": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@laravel/passkeys/-/passkeys-0.4.0.tgz", + "integrity": "sha512-59UHJHZPrIT8f5hFQJUR1EXhazWZTg3DcJefxSfA3aRj6xu40I/lYDveUY4oL2/h/crq9jjptSKykZDDold7aA==", + "license": "MIT", + "dependencies": { + "@simplewebauthn/browser": "^13.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "svelte": "^5.0.0", + "vue": "^3.3.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/@oxc-project/types": { "version": "0.143.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", @@ -360,6 +389,12 @@ "dev": true, "license": "MIT" }, + "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/@tailwindcss/forms": { "version": "0.5.11", "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", @@ -1248,7 +1283,7 @@ "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "engines": { diff --git a/package.json b/package.json index 982aee11d7..741c243bf4 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "vite": "8.2.1" }, "dependencies": { + "@laravel/passkeys": "^0.4.0", "@tailwindcss/forms": "0.5.11", "@tailwindcss/typography": "0.5.20", "@tanstack/virtual-core": "3.17.11", diff --git a/resources/js/passkeys.js b/resources/js/passkeys.js new file mode 100644 index 0000000000..060e1ad00d --- /dev/null +++ b/resources/js/passkeys.js @@ -0,0 +1,22 @@ +import { Passkeys } from '@laravel/passkeys'; + +window.Passkeys = Passkeys; + +window.passkeyDeviceName = () => { + const ua = navigator.userAgent; + const browser = /Edg\//.test(ua) ? 'Edge' + : /Firefox\//.test(ua) ? 'Firefox' + : /Chrome\//.test(ua) ? 'Chrome' + : /Safari\//.test(ua) ? 'Safari' + : 'Browser'; + const os = /iPhone|iPad/.test(ua) ? 'iOS' + : /Android/.test(ua) ? 'Android' + : /Mac/.test(ua) ? 'Mac' + : /Windows/.test(ua) ? 'Windows' + : /Linux/.test(ua) ? 'Linux' + : 'device'; + + return `${browser} on ${os}`; +}; + +window.dispatchEvent(new CustomEvent('passkeys:ready')); diff --git a/resources/views/auth/confirm-password.blade.php b/resources/views/auth/confirm-password.blade.php index 9d2bc7be54..ee5cbb0858 100644 --- a/resources/views/auth/confirm-password.blade.php +++ b/resources/views/auth/confirm-password.blade.php @@ -28,6 +28,11 @@ {{ __('auth.confirm_password') }} + + @if ($can_confirm_with_passkey) + + @endif diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 476b153985..0788e43f13 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -22,12 +22,14 @@
@csrf @env('local') - @else - @@ -78,6 +80,10 @@ + @if ($passkeys_available) + + @endif + @if ($enabled_oauth_providers->isNotEmpty())
Or continue with
diff --git a/resources/views/components/auth/passkey-verify.blade.php b/resources/views/components/auth/passkey-verify.blade.php new file mode 100644 index 0000000000..793e5998b6 --- /dev/null +++ b/resources/views/components/auth/passkey-verify.blade.php @@ -0,0 +1,55 @@ +@props([ + 'optionsRoute' => 'passkey.login-options', + 'submitRoute' => 'passkey.login', + 'label' => 'Sign in with a passkey', + 'autofill' => false, +]) + +@vite('resources/js/passkeys.js') +
+ + {{ $label }} + + +
diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php index 1c77a2d70e..b2ada6f238 100644 --- a/resources/views/livewire/profile/index.blade.php +++ b/resources/views/livewire/profile/index.blade.php @@ -300,6 +300,8 @@
+ +
diff --git a/resources/views/livewire/profile/passkeys.blade.php b/resources/views/livewire/profile/passkeys.blade.php new file mode 100644 index 0000000000..86df71597d --- /dev/null +++ b/resources/views/livewire/profile/passkeys.blade.php @@ -0,0 +1,104 @@ +
+ @if ($enabled) + @assets + @vite('resources/js/passkeys.js') + @endassets +
+
+
+

Passkeys

+

Sign in with your fingerprint, face, or a security key. A passkey sign-in skips two-factor authentication.

+
+ @if ($available && ! $show_form) + Add passkey + @endif +
+
+ @unless ($available) + + Passkeys need HTTPS on the instance domain, or localhost. An IP address does not work. + + @endunless + + @if ($available && $show_form) +
+
+ + Create passkey + Cancel +
+

+
+ @endif + + @forelse ($passkeys as $passkey) +
+
+
{{ $passkey->name }}
+
+ + Save + +

+ {{ $passkey->authenticator ?? 'Passkey' }} + · Added {{ $passkey->created_at->diffForHumans() }} + · {{ $passkey->last_used_at ? 'Last used '.$passkey->last_used_at->diffForHumans() : 'Not used yet' }} +

+
+
+ + + +
+ +
+
+
+ @empty + @if ($available && ! $show_form) + + @endif + @endforelse + + @error('name') +

{{ $message }}

+ @enderror +
+
+ @endif +
diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index 0e6242d572..f437936faa 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -32,6 +32,12 @@ ['value' => false, 'label' => 'Require two-step confirmation'], ['value' => true, 'label' => 'Skip two-step confirmation'], ]" /> +
diff --git a/resources/views/livewire/settings/index.blade.php b/resources/views/livewire/settings/index.blade.php index 848642b6bb..f5a3ae95ce 100644 --- a/resources/views/livewire/settings/index.blade.php +++ b/resources/views/livewire/settings/index.blade.php @@ -15,7 +15,7 @@ 'lg:col-span-2' => !str_starts_with(strtolower($fqdn ?? ''), 'https://'), ])>
diff --git a/routes/web.php b/routes/web.php index 3294c6edf7..4ab71748d7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -186,6 +186,9 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/profile', ProfileIndex::class)->name('profile'); Route::get('/profile/avatar', ProfileAvatarController::class)->name('profile.avatar'); Route::get('/profile/appearance', ProfileAppearance::class)->name('profile.appearance'); + Route::get('/profile/passkeys/confirm', fn () => redirect()->route('profile')->with('status', 'passkey-add')) + ->middleware('password.confirm') + ->name('profile.passkeys.confirm'); Route::prefix('tags')->group(function () { Route::get('/{tagName?}', TagsShow::class)->name('tags.show'); diff --git a/tests/Feature/Auth/PasskeyAccessTest.php b/tests/Feature/Auth/PasskeyAccessTest.php new file mode 100644 index 0000000000..2f11771a09 --- /dev/null +++ b/tests/Feature/Auth/PasskeyAccessTest.php @@ -0,0 +1,78 @@ +withoutDefer(); + config()->set('app.maintenance.store', 'array'); + InstanceSettings::forceCreate(['id' => 0]); + Once::flush(); + $this->withoutVite(); + Log::spy(); + + $this->user = User::factory()->create(['name' => 'Passkey User']); + $this->team = $this->user->teams()->sole(); +}); + +it('allows the authenticated passkey paths for unsubscribed and boarding accounts', function () { + expect(allowedPathsForBoardingAccounts())->toContain( + 'user/passkeys', + 'user/passkeys/options', + 'passkeys/confirm', + 'passkeys/confirm/options', + 'profile/passkeys/confirm', + ); +}); + +it('sets the session team after a login that did not set one', function () { + $this->actingAs($this->user)->get('/profile')->assertOk(); + + expect(session('currentTeam')->id)->toBe($this->team->id); +}); + +it('sends a multi team user without a stored team to the team selection', function () { + $other = Team::factory()->create(); + $other->members()->attach($this->user->id, ['role' => 'member']); + + $this->actingAs($this->user)->get('/profile')->assertRedirect(route('team.select')); +}); + +it('asks for the password before the add passkey form opens', function () { + $this->actingAs($this->user) + ->withSession(['currentTeam' => $this->team]) + ->get(route('profile.passkeys.confirm')) + ->assertRedirect(route('password.confirm')); + + $this->actingAs($this->user) + ->withSession(['currentTeam' => $this->team, 'auth.password_confirmed_at' => time()]) + ->get(route('profile.passkeys.confirm')) + ->assertRedirect(route('profile')) + ->assertSessionHas('status', 'passkey-add'); +}); + +it('records audit events for passkey changes', function (string $eventClass, string $auditName) { + $passkey = $this->user->passkeys()->create([ + 'name' => 'Laptop', + 'credential_id' => 'credential-1', + 'credential' => ['aaguid' => '00000000-0000-0000-0000-000000000000'], + ]); + + event(new $eventClass($this->user, $passkey)); + + expect(AuditEvent::query()->where('event', $auditName)->count())->toBe(1); +})->with([ + [PasskeyRegistered::class, 'auth.user.passkey_registered'], + [PasskeyVerified::class, 'auth.user.passkey_verified'], + [PasskeyDeleted::class, 'auth.user.passkey_deleted'], +]); diff --git a/tests/Feature/Auth/PasskeyInstanceToggleTest.php b/tests/Feature/Auth/PasskeyInstanceToggleTest.php new file mode 100644 index 0000000000..facac26208 --- /dev/null +++ b/tests/Feature/Auth/PasskeyInstanceToggleTest.php @@ -0,0 +1,39 @@ +withoutDefer(); + InstanceSettings::forceCreate(['id' => 0]); + Once::flush(); +}); + +it('lets an instance admin turn passkeys off and keeps stored passkeys', function () { + $rootTeam = Team::factory()->create(['id' => 0]); + $admin = User::factory()->create(); + $rootTeam->members()->attach($admin->id, ['role' => 'owner']); + $admin->passkeys()->create([ + 'name' => 'Laptop', + 'credential_id' => 'credential-1', + 'credential' => ['aaguid' => '00000000-0000-0000-0000-000000000000'], + ]); + + $this->actingAs($admin); + session(['currentTeam' => $rootTeam]); + + Livewire::test(Advanced::class) + ->assertSet('is_passkey_enabled', true) + ->set('is_passkey_enabled', false) + ->call('instantSave'); + + expect(InstanceSettings::findOrFail(0)->is_passkey_enabled)->toBeFalse() + ->and($admin->passkeys()->count())->toBe(1); +}); diff --git a/tests/Feature/Auth/PasskeyRelyingPartyTest.php b/tests/Feature/Auth/PasskeyRelyingPartyTest.php new file mode 100644 index 0000000000..d6008717e1 --- /dev/null +++ b/tests/Feature/Auth/PasskeyRelyingPartyTest.php @@ -0,0 +1,100 @@ +set('app.maintenance.store', 'array'); + InstanceSettings::forceCreate(['id' => 0]); + Once::flush(); + $this->withoutVite(); +}); + +function setPasskeyInstance(?string $fqdn, string $appUrl, bool $enabled = true): void +{ + InstanceSettings::query()->where('id', 0)->update(['fqdn' => $fqdn, 'is_passkey_enabled' => $enabled]); + config()->set('app.url', $appUrl); + Once::flush(); +} + +it('resolves the relying party', function (?string $fqdn, string $appUrl, string $requestUrl, ?array $expected) { + setPasskeyInstance($fqdn, $appUrl); + + expect(PasskeyRelyingParty::resolve(Request::create($requestUrl)))->toBe($expected); +})->with([ + 'https fqdn' => ['https://coolify.example.com', 'http://localhost', 'https://coolify.example.com/login', + ['id' => 'coolify.example.com', 'origins' => ['https://coolify.example.com']]], + 'fqdn behind http proxy' => ['https://coolify.example.com', 'http://localhost', 'http://coolify.example.com/login', + ['id' => 'coolify.example.com', 'origins' => ['https://coolify.example.com']]], + 'app url fallback keeps the port' => [null, 'https://dev.example.ts.net:8443', 'https://dev.example.ts.net:8443/login', + ['id' => 'dev.example.ts.net', 'origins' => ['https://dev.example.ts.net:8443']]], + 'localhost uses the request origin' => [null, 'http://localhost', 'http://localhost:8000/login', + ['id' => 'localhost', 'origins' => ['http://localhost:8000']]], + 'fqdn on a custom port' => ['https://coolify.example.com', 'http://localhost', 'https://coolify.example.com:8443/login', + ['id' => 'coolify.example.com', 'origins' => ['https://coolify.example.com', 'https://coolify.example.com:8443']]], + 'fqdn with uppercase letters' => ['https://Coolify.Example.com', 'http://localhost', 'https://coolify.example.com/login', + ['id' => 'coolify.example.com', 'origins' => ['https://coolify.example.com']]], + 'http fqdn' => ['http://coolify.example.com', 'http://localhost', 'http://coolify.example.com/login', null], + 'raw ip' => [null, 'https://203.0.113.10', 'https://203.0.113.10/login', null], +]); + +it('is not available on a host that is not the instance host', function () { + setPasskeyInstance('https://coolify.example.com', 'http://localhost'); + + expect(PasskeyRelyingParty::isAvailable(Request::create('http://203.0.113.10:8000/login')))->toBeFalse(); +}); + +it('is not available when the instance toggle is off', function () { + setPasskeyInstance(null, 'http://localhost', enabled: false); + + expect(PasskeyRelyingParty::isAvailable(Request::create('http://localhost/login')))->toBeFalse(); +}); + +it('returns login options with the resolved relying party', function () { + setPasskeyInstance('https://coolify.example.com', 'http://localhost'); + + $this->getJson('https://coolify.example.com/passkeys/login/options') + ->assertOk() + ->assertJsonPath('options.rpId', 'coolify.example.com'); +}); + +it('returns 404 on passkey routes when passkeys are not available', function (bool $enabled, string $url) { + setPasskeyInstance(null, 'http://localhost', $enabled); + + $this->getJson($url)->assertNotFound(); +})->with([ + 'toggle off' => [false, 'http://localhost/passkeys/login/options'], + 'raw ip host' => [true, 'http://203.0.113.10:8000/passkeys/login/options'], +]); + +it('keeps the password login page reachable when passkeys are not available', function () { + setPasskeyInstance(null, 'http://localhost', enabled: false); + User::factory()->create(); + + $this->get('http://203.0.113.10:8000/login')->assertOk(); +}); + +it('rate limits the passkey routes by client ip', function () { + setPasskeyInstance(null, 'http://localhost'); + + foreach (range(1, 30) as $attempt) { + $this->getJson('/passkeys/login/options')->assertOk(); + } + + $this->getJson('/passkeys/login/options')->assertStatus(429); +}); + +it('does not need the instance settings on other fortify routes', function () { + InstanceSettings::query()->delete(); + Once::flush(); + + $this->post('/login', ['email' => 'nobody@example.com', 'password' => 'wrong-password']) + ->assertRedirect() + ->assertSessionHasErrors('email'); +}); diff --git a/tests/Feature/Auth/PasskeySetupTest.php b/tests/Feature/Auth/PasskeySetupTest.php new file mode 100644 index 0000000000..edd2b39c62 --- /dev/null +++ b/tests/Feature/Auth/PasskeySetupTest.php @@ -0,0 +1,42 @@ + 0]); + Once::flush(); +}); + +it('registers the fortify passkey routes', function () { + expect(Route::has('passkey.login-options'))->toBeTrue() + ->and(Route::has('passkey.login'))->toBeTrue() + ->and(Route::has('passkey.confirm'))->toBeTrue() + ->and(Route::has('passkey.store'))->toBeTrue(); +}); + +it('returns a stable 64 character hex user handle', function () { + $user = User::factory()->create(); + + $handle = $user->getPasskeyUserHandle(); + + expect($handle)->toMatch('/^[0-9a-f]{64}$/') + ->and(User::find($user->id)->getPasskeyUserHandle())->toBe($handle); +}); + +it('enables passkeys by default and stores passkeys for a user', function () { + $user = User::factory()->create(); + $user->passkeys()->create([ + 'name' => 'Laptop', + 'credential_id' => 'credential-1', + 'credential' => ['aaguid' => '00000000-0000-0000-0000-000000000000'], + ]); + + expect(instanceSettings()->is_passkey_enabled)->toBeTrue() + ->and($user->hasPasskeysEnabled())->toBeTrue(); +}); diff --git a/tests/Feature/ProfilePasskeysTest.php b/tests/Feature/ProfilePasskeysTest.php new file mode 100644 index 0000000000..b4d44d65b2 --- /dev/null +++ b/tests/Feature/ProfilePasskeysTest.php @@ -0,0 +1,137 @@ + 0]); + Once::flush(); + $this->withoutVite(); + + $this->user = User::factory()->create(['name' => 'Passkey User', 'password' => Hash::make('secret-password')]); + $this->actingAs($this->user); + session(['currentTeam' => $this->user->teams()->sole()]); +}); + +function profilePasskey(User $user, string $name = 'Laptop'): Passkey +{ + return $user->passkeys()->create([ + 'name' => $name, + 'credential_id' => 'credential-'.$user->id.'-'.$name, + 'credential' => ['aaguid' => '00000000-0000-0000-0000-000000000000'], + ]); +} + +it('lists only the passkeys of the current user', function () { + profilePasskey($this->user, 'My laptop'); + profilePasskey(User::factory()->create(), 'Other phone'); + + Livewire::test(Passkeys::class) + ->assertSee('My laptop') + ->assertDontSee('Other phone'); +}); + +it('opens the add form only after the confirm redirect', function () { + Livewire::test(Passkeys::class)->assertSet('show_form', false); + + session()->flash('status', 'passkey-add'); + + Livewire::test(Passkeys::class)->assertSet('show_form', true); +}); + +it('renames a passkey of the current user', function () { + $passkey = profilePasskey($this->user); + + Livewire::test(Passkeys::class)->call('rename', $passkey->id, ' Work laptop '); + + expect($passkey->fresh()->name)->toBe('Work laptop'); +}); + +it('rejects an empty or too long passkey name', function (string $name) { + $passkey = profilePasskey($this->user); + + Livewire::test(Passkeys::class)->call('rename', $passkey->id, $name)->assertHasErrors('name'); + + expect($passkey->fresh()->name)->toBe('Laptop'); +})->with(['empty' => [' '], 'too long' => [str_repeat('a', 256)]]); + +it('does not rename the passkey of another user', function () { + $passkey = profilePasskey(User::factory()->create()); + + expect(fn () => Livewire::test(Passkeys::class)->call('rename', $passkey->id, 'Stolen')) + ->toThrow(ModelNotFoundException::class); + + expect($passkey->fresh()->name)->toBe('Laptop'); +}); + +it('deletes a passkey with the correct password', function () { + $passkey = profilePasskey($this->user); + + Livewire::test(Passkeys::class)->call('delete', $passkey->id, 'secret-password'); + + expect(Passkey::find($passkey->id))->toBeNull(); +}); + +it('does not delete a passkey with a wrong password', function () { + $passkey = profilePasskey($this->user); + + Livewire::test(Passkeys::class)->call('delete', $passkey->id, 'wrong-password'); + + expect(Passkey::find($passkey->id))->not->toBeNull(); +}); + +it('does not delete the passkey of another user', function () { + $passkey = profilePasskey(User::factory()->create()); + + expect(fn () => Livewire::test(Passkeys::class)->call('delete', $passkey->id, 'secret-password')) + ->toThrow(ModelNotFoundException::class); + + expect(Passkey::find($passkey->id))->not->toBeNull(); +}); + +it('renders nothing when passkeys are turned off', function () { + InstanceSettings::query()->where('id', 0)->update(['is_passkey_enabled' => false]); + Once::flush(); + profilePasskey($this->user, 'My laptop'); + + Livewire::test(Passkeys::class)->assertDontSee('My laptop')->assertDontSee('Passkeys'); +}); + +it('refuses passkey changes when passkeys are turned off', function (string $action, array $arguments) { + $passkey = profilePasskey($this->user); + InstanceSettings::query()->where('id', 0)->update(['is_passkey_enabled' => false]); + Once::flush(); + + Livewire::test(Passkeys::class)->call($action, $passkey->id, ...$arguments)->assertNotFound(); + + expect($passkey->fresh()?->name)->toBe('Laptop'); +})->with([ + 'rename' => ['rename', ['Renamed']], + 'delete' => ['delete', ['secret-password']], +]); + +it('explains why a passkey cannot be added on an address that does not support passkeys', function () { + profilePasskey($this->user, 'My laptop'); + + $this->get('http://203.0.113.10:8000/profile') + ->assertOk() + ->assertSee('My laptop') + ->assertSee('Passkeys need HTTPS') + ->assertDontSee('Add passkey'); +}); + +it('reports if a rename was saved', function () { + $passkey = profilePasskey($this->user); + + Livewire::test(Passkeys::class)->call('rename', $passkey->id, 'Work laptop')->assertReturned(true); + Livewire::test(Passkeys::class)->call('rename', $passkey->id, ' ')->assertReturned(null); +}); diff --git a/vite.config.js b/vite.config.js index 1c7c5c47b3..efbe6b8fe1 100644 --- a/vite.config.js +++ b/vite.config.js @@ -43,6 +43,7 @@ export default defineConfig(({ mode }) => { input: [ "resources/css/app.css", "resources/js/app.js", + "resources/js/passkeys.js", ], refresh: true, }), From 12ffea0ab246611588fa3e7e0e83ce43fb434679 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Tue, 13 Oct 2026 06:54:26 +0000 Subject: [PATCH 2/3] refactor(auth): merge the passkey migrations into one --- ...026_10_12_164601_create_passkeys_table.php | 8 ++++++ ...key_enabled_to_instance_settings_table.php | 28 ------------------- 2 files changed, 8 insertions(+), 28 deletions(-) delete mode 100644 database/migrations/2026_10_12_164603_add_is_passkey_enabled_to_instance_settings_table.php diff --git a/database/migrations/2026_10_12_164601_create_passkeys_table.php b/database/migrations/2026_10_12_164601_create_passkeys_table.php index cb884c3988..97d6bf59b1 100644 --- a/database/migrations/2026_10_12_164601_create_passkeys_table.php +++ b/database/migrations/2026_10_12_164601_create_passkeys_table.php @@ -22,6 +22,10 @@ return new class extends Migration $table->index('user_id'); }); + + Schema::table('instance_settings', function (Blueprint $table) { + $table->boolean('is_passkey_enabled')->default(true); + }); } /** @@ -29,6 +33,10 @@ return new class extends Migration */ public function down(): void { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('is_passkey_enabled'); + }); + Schema::dropIfExists('passkeys'); } }; diff --git a/database/migrations/2026_10_12_164603_add_is_passkey_enabled_to_instance_settings_table.php b/database/migrations/2026_10_12_164603_add_is_passkey_enabled_to_instance_settings_table.php deleted file mode 100644 index 674486416d..0000000000 --- a/database/migrations/2026_10_12_164603_add_is_passkey_enabled_to_instance_settings_table.php +++ /dev/null @@ -1,28 +0,0 @@ -boolean('is_passkey_enabled')->default(true); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('instance_settings', function (Blueprint $table) { - $table->dropColumn('is_passkey_enabled'); - }); - } -}; From 2fa4d06ed6acc0a9667ca5b5431ff2b1c887c3f2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:09:48 +0200 Subject: [PATCH 3/3] fix(auth): allow restricted accounts to confirm passwords for passkeys --- bootstrap/helpers/subscriptions.php | 1 + tests/Feature/Auth/PasskeyAccessTest.php | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/bootstrap/helpers/subscriptions.php b/bootstrap/helpers/subscriptions.php index afe1631a19..95442ca359 100644 --- a/bootstrap/helpers/subscriptions.php +++ b/bootstrap/helpers/subscriptions.php @@ -86,6 +86,7 @@ function allowedPathsForUnsubscribedAccounts() 'profile', 'profile/appearance', 'profile/passkeys/confirm', + 'user/confirm-password', 'user/passkeys', 'user/passkeys/options', 'passkeys/confirm', diff --git a/tests/Feature/Auth/PasskeyAccessTest.php b/tests/Feature/Auth/PasskeyAccessTest.php index 2f11771a09..8d2a9d5fca 100644 --- a/tests/Feature/Auth/PasskeyAccessTest.php +++ b/tests/Feature/Auth/PasskeyAccessTest.php @@ -61,6 +61,26 @@ it('asks for the password before the add passkey form opens', function () { ->assertSessionHas('status', 'passkey-add'); }); +it('lets restricted accounts confirm their password before adding a passkey', function (bool $selfHosted) { + config()->set('constants.coolify.self_hosted', $selfHosted); + $this->team->update(['show_boarding' => true]); + $this->actingAs($this->user)->withSession(['currentTeam' => $this->team]); + + $this->get(route('profile.passkeys.confirm'))->assertRedirect(route('password.confirm')); + $this->get(route('password.confirm'))->assertOk(); + $this->post(route('password.confirm.store'), ['password' => 'wrong-password']) + ->assertSessionHasErrors('password'); + expect(session('auth.password_confirmed_at'))->toBeNull(); + + $this->post(route('password.confirm.store'), ['password' => 'password']) + ->assertRedirect(route('profile.passkeys.confirm')); + expect(session('auth.password_confirmed_at'))->toBeInt(); + $this->get(route('profile.passkeys.confirm')) + ->assertRedirect(route('profile')) + ->assertSessionHas('status', 'passkey-add'); + $this->getJson(route('passkey.registration-options'))->assertOk(); +})->with(['onboarding' => true, 'cloud without a subscription' => false]); + it('records audit events for passkey changes', function (string $eventClass, string $auditName) { $passkey = $this->user->passkeys()->create([ 'name' => 'Laptop',