diff --git a/app/Actions/Passkeys/ConfigureCeremonyStepManagerFactoryAction.php b/app/Actions/Passkeys/ConfigureCeremonyStepManagerFactoryAction.php
new file mode 100644
index 000000000..53dbba99f
--- /dev/null
+++ b/app/Actions/Passkeys/ConfigureCeremonyStepManagerFactoryAction.php
@@ -0,0 +1,32 @@
+getSchemeAndHttpHost()];
+
+ // HTTPS origins for normal environments.
+ $origins[] = "https://{$rpId}";
+
+ // Local development allowance for localhost over HTTP.
+ if ($rpId === 'localhost') {
+ $origins[] = 'http://localhost';
+ }
+
+ $factory->setAllowedOrigins(array_values(array_unique($origins)));
+
+ return $factory;
+ }
+}
diff --git a/app/Actions/Passkeys/FindPasskeyToAuthenticateAction.php b/app/Actions/Passkeys/FindPasskeyToAuthenticateAction.php
new file mode 100644
index 000000000..a07c7c554
--- /dev/null
+++ b/app/Actions/Passkeys/FindPasskeyToAuthenticateAction.php
@@ -0,0 +1,51 @@
+execute();
+ $requestCsm = $csmFactory->requestCeremony();
+
+ $host = RelyingPartyIdResolver::resolve();
+
+ try {
+ $validator = AuthenticatorAssertionResponseValidator::create($requestCsm);
+
+ $publicKeyCredentialSource = $validator->check(
+ $passkey->data,
+ $publicKeyCredential->response,
+ $passkeyOptions,
+ $host,
+ null,
+ );
+ } catch (Throwable) {
+ return null;
+ }
+
+ return CredentialRecordConverter::toPublicKeyCredentialSource($publicKeyCredentialSource);
+ }
+}
diff --git a/app/Actions/Passkeys/GeneratePasskeyAuthenticationOptionsAction.php b/app/Actions/Passkeys/GeneratePasskeyAuthenticationOptionsAction.php
new file mode 100644
index 000000000..9b0689784
--- /dev/null
+++ b/app/Actions/Passkeys/GeneratePasskeyAuthenticationOptionsAction.php
@@ -0,0 +1,32 @@
+toJson($options);
+
+ Session::flash('passkey-authentication-options', $options);
+
+ return $options;
+ }
+}
diff --git a/app/Actions/Passkeys/GeneratePasskeyRegisterOptionsAction.php b/app/Actions/Passkeys/GeneratePasskeyRegisterOptionsAction.php
new file mode 100644
index 000000000..3a387b45f
--- /dev/null
+++ b/app/Actions/Passkeys/GeneratePasskeyRegisterOptionsAction.php
@@ -0,0 +1,59 @@
+ 'public-key', 'alg' => -7], // ES256
+ ['type' => 'public-key', 'alg' => -257], // RS256
+ ];
+
+ // Different serializer/browser integrations can use either shape:
+ // - options.pubKeyCredParams (WebAuthn JSON)
+ // - options.publicKey.pubKeyCredParams (navigator.credentials.create payload)
+ // Enforce valid algorithms for both to prevent "alg undefined" errors.
+ $decoded['pubKeyCredParams'] = $supportedAlgorithms;
+
+ if (isset($decoded['publicKey']) && is_array($decoded['publicKey'])) {
+ $decoded['publicKey']['pubKeyCredParams'] = $supportedAlgorithms;
+ }
+
+ return json_encode($decoded, JSON_THROW_ON_ERROR);
+ }
+
+ protected function relatedPartyEntity(): PublicKeyCredentialRpEntity
+ {
+ $rpId = RelyingPartyIdResolver::resolve();
+
+ return new PublicKeyCredentialRpEntity(
+ name: (string) Config::getRelyingPartyName(),
+ id: $rpId,
+ icon: Config::getRelyingPartyIcon(),
+ );
+ }
+}
diff --git a/app/Http/Controllers/Admin/AdminUserController.php b/app/Http/Controllers/Admin/AdminUserController.php
index 7d53eedb5..342690557 100644
--- a/app/Http/Controllers/Admin/AdminUserController.php
+++ b/app/Http/Controllers/Admin/AdminUserController.php
@@ -12,9 +12,12 @@ use App\Models\User;
use App\Models\UserDownload;
use App\Models\UserRequest;
use Illuminate\Contracts\View\View;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
+use Spatie\LaravelPasskeys\Models\Passkey;
use Spatie\Permission\Models\Role;
class AdminUserController extends BasePageController
@@ -148,7 +151,7 @@ class AdminUserController extends BasePageController
// Check if role is changing and get stack preference
$roleChanged = $editedUser->roles_id != $request->input('role');
$stackRole = $request->input('stack_role') ? true : false; // Check if checkbox is checked
- $changedBy = auth()->check() ? auth()->id() : null;
+ $changedBy = Auth::check() ? Auth::id() : null;
// CRITICAL: Capture the ORIGINAL rolechangedate BEFORE any updates
// This is needed for accurate role history tracking
@@ -269,6 +272,8 @@ class AdminUserController extends BasePageController
// Add daily API and download counts
if ($user) {
+ $user->load('passkeys');
+
try {
$user->daily_api_count = UserRequest::getApiRequests($user->id);
$user->daily_download_count = UserDownload::getDownloadRequests($user->id);
@@ -349,4 +354,63 @@ class AdminUserController extends BasePageController
return redirect()->back()->with('error', 'User is invalid');
}
+
+ public function destroyPasskey(Request $request, Passkey $passkey): RedirectResponse|JsonResponse
+ {
+ $targetUser = $passkey->authenticatable;
+ $targetUserId = $targetUser?->id;
+ $targetUsername = $targetUser?->username;
+ $passkeyName = $passkey->name;
+
+ $passkey->delete();
+
+ Log::channel('admin')->info('Admin deleted user passkey', [
+ 'admin_user_id' => Auth::id(),
+ 'target_user_id' => $targetUserId,
+ 'target_username' => $targetUsername,
+ 'passkey_id' => $passkey->id,
+ 'passkey_name' => $passkeyName,
+ ]);
+
+ if ($request->expectsJson()) {
+ return response()->json(['ok' => true]);
+ }
+
+ $redirectUrl = $targetUserId
+ ? 'admin/user-edit?id='.$targetUserId
+ : 'admin/user-list';
+
+ return redirect()->to($redirectUrl)->with('success', 'Passkey deleted successfully.');
+ }
+
+ public function wipePasskeys(Request $request): RedirectResponse|JsonResponse
+ {
+ $validated = $request->validate([
+ 'user_id' => ['required', 'integer', 'exists:users,id'],
+ 'confirmation' => ['required', 'string', 'in:WIPE'],
+ ]);
+
+ $target = User::findOrFail((int) $validated['user_id']);
+ $wipedCount = $target->passkeys()->count();
+ $target->passkeys()->delete();
+
+ Log::channel('admin')->warning('Admin wiped user passkeys', [
+ 'admin_user_id' => Auth::id(),
+ 'target_user_id' => $target->id,
+ 'target_username' => $target->username,
+ 'wiped_count' => $wipedCount,
+ ]);
+
+ $message = "{$wipedCount} passkeys removed from {$target->username}. They can now regain access with their password or an admin-issued reset.";
+
+ if ($request->expectsJson()) {
+ return response()->json([
+ 'ok' => true,
+ 'wiped_count' => $wipedCount,
+ 'message' => $message,
+ ]);
+ }
+
+ return redirect()->to('admin/user-edit?id='.$target->id)->with('success', $message);
+ }
}
diff --git a/app/Http/Controllers/Auth/PasskeyLoginController.php b/app/Http/Controllers/Auth/PasskeyLoginController.php
new file mode 100644
index 000000000..605663e6c
--- /dev/null
+++ b/app/Http/Controllers/Auth/PasskeyLoginController.php
@@ -0,0 +1,108 @@
+input($captchaField);
+
+ // Passkey login should not be blocked when captcha widgets rotate/expire
+ // during WebAuthn prompts. If a captcha token is present, still validate it.
+ if ($captchaRules !== [] && is_string($captchaValue) && trim($captchaValue) !== '') {
+ $validator = Validator::make($request->all(), $captchaRules);
+ if ($validator->fails()) {
+ Log::channel('failed_login')->error(
+ 'Failed passkey login captcha check from IP address: '.$request->ip()
+ );
+
+ session()->flash('authenticatePasskey::message', $validator->errors()->first());
+ session()->flash('authenticatePasskey::reason', 'captcha');
+
+ return back();
+ }
+ }
+
+ $findAuthenticatableUsingPasskey = Config::getAction(
+ 'find_passkey',
+ FindPasskeyToAuthenticateAction::class
+ );
+
+ $passkey = $findAuthenticatableUsingPasskey->execute(
+ $request->input('start_authentication_response'),
+ Session::get('passkey-authentication-options'),
+ );
+
+ if (! $passkey || ! $passkey->authenticatable instanceof User) {
+ Log::channel('failed_login')->error(
+ 'Failed passkey login attempt from IP address: '.$request->ip()
+ );
+
+ session()->flash('authenticatePasskey::message', __('passkeys::passkeys.invalid'));
+ session()->flash('authenticatePasskey::reason', 'invalid_passkey');
+
+ return back();
+ }
+
+ $user = $passkey->authenticatable;
+
+ if ($user->trashed()) {
+ Log::channel('failed_login')->error(
+ 'Failed passkey login for deactivated user: '.$user->username.' from IP address: '.$request->ip()
+ );
+
+ session()->flash(
+ 'authenticatePasskey::message',
+ 'This account has been deactivated. Please contact us through contact form to have your account reactivated.'
+ );
+
+ return back();
+ }
+
+ if (! $user->hasVerifiedEmail()) {
+ Log::channel('failed_login')->error(
+ 'Failed passkey login for unverified user: '.$user->username.' from IP address: '.$request->ip()
+ );
+
+ session()->flash('authenticatePasskey::message', 'You have not verified your email address!');
+
+ return back();
+ }
+
+ Auth::login($user, $request->boolean('remember'));
+ $request->session()->regenerate();
+
+ // Passkey auth is treated as sufficient MFA, so skip additional OTP gate.
+ session([config('google2fa.session_var') => true]);
+ session([config('google2fa.session_var').'.auth.passed_at' => time()]);
+
+ $userIp = config('nntmux:settings.store_user_ip') ? ($request->ip() ?? $request->getClientIp()) : '';
+ event(new UserLoggedIn($user, $userIp));
+ event(new PasskeyUsedToAuthenticateEvent($passkey, $request));
+
+ $url = Session::has('passkeys.redirect')
+ ? (string) Session::pull('passkeys.redirect')
+ : (string) config('passkeys.redirect_to_after_login', '/');
+
+ return redirect($url)->with('info', 'You have been logged in');
+ }
+}
diff --git a/app/Http/Controllers/Auth/PasskeyManagementController.php b/app/Http/Controllers/Auth/PasskeyManagementController.php
new file mode 100644
index 000000000..0e48f8574
--- /dev/null
+++ b/app/Http/Controllers/Auth/PasskeyManagementController.php
@@ -0,0 +1,107 @@
+validate([
+ 'name' => ['required', 'string', 'max:255'],
+ ]);
+
+ $generatePassKeyOptionsAction = Config::getAction(
+ 'generate_passkey_register_options',
+ GeneratePasskeyRegisterOptionsAction::class
+ );
+
+ /** @var User $user */
+ $user = Auth::user();
+ $options = $generatePassKeyOptionsAction->execute($user);
+ $request->session()->put('passkey-registration-options', $options);
+ $request->session()->put('passkey-registration-name', $validated['name']);
+
+ return response()->json([
+ 'ok' => true,
+ 'options' => json_decode($options, true, 512, JSON_THROW_ON_ERROR),
+ ]);
+ }
+
+ public function store(Request $request): JsonResponse|RedirectResponse
+ {
+ $validated = $request->validate([
+ 'name' => ['required', 'string', 'max:255'],
+ 'passkey' => ['required', 'string'],
+ ]);
+
+ $storePasskeyAction = Config::getAction('store_passkey', StorePasskeyAction::class);
+
+ try {
+ /** @var User $user */
+ $user = Auth::user();
+ /** @var Passkey $passkey */
+ $passkey = $storePasskeyAction->execute(
+ $user,
+ $validated['passkey'],
+ $request->session()->pull('passkey-registration-options'),
+ RelyingPartyIdResolver::resolve($request),
+ ['name' => $validated['name']]
+ );
+ } catch (Throwable $exception) {
+ Log::error('Passkey registration failed', [
+ 'user_id' => Auth::id(),
+ 'host' => $request->getHost(),
+ 'resolved_rp_id' => RelyingPartyIdResolver::resolve($request),
+ 'exception' => $exception::class,
+ 'message' => $exception->getMessage(),
+ ]);
+
+ return response()->json([
+ 'ok' => false,
+ 'message' => __('passkeys::passkeys.error_something_went_wrong_generating_the_passkey'),
+ ], 422);
+ }
+
+ return response()->json([
+ 'ok' => true,
+ 'passkey' => [
+ 'id' => $passkey?->id,
+ 'name' => $passkey?->name,
+ 'last_used_at' => $passkey?->last_used_at?->toIso8601String(),
+ 'created_at' => $passkey?->created_at?->toIso8601String(),
+ ],
+ ]);
+ }
+
+ public function destroy(Request $request, int $passkey): JsonResponse|RedirectResponse
+ {
+ /** @var User $user */
+ $user = Auth::user();
+ $deleted = $user->passkeys()->where('id', $passkey)->delete();
+
+ if ($request->expectsJson()) {
+ return response()->json(['ok' => $deleted > 0]);
+ }
+
+ return back()->with(
+ $deleted > 0 ? 'success' : 'error',
+ $deleted > 0 ? 'Passkey deleted.' : 'Passkey not found.'
+ );
+ }
+}
diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php
index a91437419..bcd203c80 100644
--- a/app/Http/Controllers/ProfileController.php
+++ b/app/Http/Controllers/ProfileController.php
@@ -112,6 +112,7 @@ class ProfileController extends BasePageController
$action = $request->input('action') ?? 'view';
$userid = $this->userdata->id;
+ $this->userdata->loadMissing('passkeys');
$errorStr = '';
$success_2fa = $request->session()->get('success');
diff --git a/app/Models/User.php b/app/Models/User.php
index 1cf0e2b75..5fe49c1d3 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -34,6 +34,9 @@ use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
+use Spatie\LaravelPasskeys\Models\Concerns\HasPasskeys;
+use Spatie\LaravelPasskeys\Models\Concerns\InteractsWithPasskeys;
+use Spatie\LaravelPasskeys\Models\Passkey;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Traits\HasRoles;
@@ -104,6 +107,7 @@ use Spatie\Permission\Traits\HasRoles;
* @property-read Collection $series
* @property-read Collection $promotionStats
* @property-read Collection $roleHistory
+ * @property-read Collection $passkeys
* @property-read Role|null $role
* @property-read PasswordSecurity|null $passwordSecurity
*
@@ -117,10 +121,11 @@ use Spatie\Permission\Traits\HasRoles;
* @method static Builder|User expiringSoon(int $days = 7)
* @method static Builder|User expired()
*/
-final class User extends Authenticatable implements MustVerifyEmailContract
+final class User extends Authenticatable implements HasPasskeys, MustVerifyEmailContract
{
use HasFactory; // @phpstan-ignore missingType.generics
use HasRoles;
+ use InteractsWithPasskeys;
use MustVerifyEmailTrait;
use Notifiable;
use SoftDeletes;
@@ -192,6 +197,21 @@ final class User extends Authenticatable implements MustVerifyEmailContract
];
}
+ public function getPasskeyName(): string
+ {
+ return $this->email ?: $this->username;
+ }
+
+ public function getPasskeyId(): string
+ {
+ return (string) $this->id;
+ }
+
+ public function getPasskeyDisplayName(): string
+ {
+ return $this->full_name ?: $this->username;
+ }
+
protected function getDefaultGuardName(): string
{
return 'web';
diff --git a/app/Support/Passkeys/RelyingPartyIdResolver.php b/app/Support/Passkeys/RelyingPartyIdResolver.php
new file mode 100644
index 000000000..b40d3c6e9
--- /dev/null
+++ b/app/Support/Passkeys/RelyingPartyIdResolver.php
@@ -0,0 +1,57 @@
+header('X-Forwarded-Host', '')),
+ self::firstHost((string) $request->header('X-Original-Host', '')),
+ (string) config('passkeys.relying_party.id', ''),
+ (string) $request->getHost(),
+ ];
+
+ foreach ($candidates as $candidate) {
+ if (self::isValidRelyingPartyId($candidate)) {
+ return $candidate;
+ }
+ }
+
+ return 'localhost';
+ }
+
+ private static function firstHost(string $value): string
+ {
+ $host = trim(explode(',', $value)[0] ?? '');
+
+ if ($host === '') {
+ return '';
+ }
+
+ // Strip optional port suffix.
+ return trim((string) preg_replace('/:\d+$/', '', $host));
+ }
+
+ private static function isValidRelyingPartyId(string $value): bool
+ {
+ $value = trim($value);
+
+ if ($value === '' || filter_var($value, FILTER_VALIDATE_IP) !== false) {
+ return false;
+ }
+
+ if ($value === 'localhost') {
+ return true;
+ }
+
+ return str_contains($value, '.');
+ }
+}
diff --git a/composer.json b/composer.json
index 91692efe5..0f7b18e03 100644
--- a/composer.json
+++ b/composer.json
@@ -69,6 +69,7 @@
"spatie/laravel-directory-cleanup": "^1.10",
"spatie/laravel-fractal": "^6.3",
"spatie/laravel-image-optimizer": "^1.8",
+ "spatie/laravel-passkeys": "^1.7",
"spatie/laravel-permission": "^7.0.0",
"stechstudio/laravel-zipstream": "^5.4",
"symfony/process": "^7.0",
diff --git a/composer.lock b/composer.lock
index 745f793b1..2bab10f4f 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "3a34364f86e7322cb77bf01b515aa6f6",
+ "content-hash": "07cc99b3bba0a8670515889435ba9f56",
"packages": [
{
"name": "aharen/omdbapi",
@@ -6599,6 +6599,84 @@
],
"time": "2026-02-21T12:49:54+00:00"
},
+ {
+ "name": "spatie/laravel-passkeys",
+ "version": "1.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/spatie/laravel-passkeys.git",
+ "reference": "2d67943712243c23852c77d4c4679575bad5b9ec"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/spatie/laravel-passkeys/zipball/2d67943712243c23852c77d4c4679575bad5b9ec",
+ "reference": "2d67943712243c23852c77d4c4679575bad5b9ec",
+ "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.7.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Spatie",
+ "type": "github"
+ }
+ ],
+ "time": "2026-04-08T07:32:15+00:00"
+ },
{
"name": "spatie/laravel-permission",
"version": "7.3.0",
@@ -6747,6 +6825,187 @@
],
"time": "2026-01-12T07:42:22+00:00"
},
+ {
+ "name": "spomky-labs/cbor-php",
+ "version": "3.2.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Spomky-Labs/cbor-php.git",
+ "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32",
+ "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32",
+ "shasum": ""
+ },
+ "require": {
+ "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
+ "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.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Spomky",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/FlorentMorselli",
+ "type": "patreon"
+ }
+ ],
+ "time": "2026-04-01T12:15:20+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": "stechstudio/laravel-zipstream",
"version": "5.10",
@@ -10400,6 +10659,164 @@
],
"time": "2024-07-03T16:05:14+00:00"
},
+ {
+ "name": "web-auth/cose-lib",
+ "version": "4.5.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/web-auth/cose-lib.git",
+ "reference": "3185af4df10dc537b65c140c315b88d15ae15b80"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/3185af4df10dc537b65c140c315b88d15ae15b80",
+ "reference": "3185af4df10dc537b65c140c315b88d15ae15b80",
+ "shasum": ""
+ },
+ "require": {
+ "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
+ "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.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Spomky",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/FlorentMorselli",
+ "type": "patreon"
+ }
+ ],
+ "time": "2026-04-01T12:47:39+00:00"
+ },
+ {
+ "name": "web-auth/webauthn-lib",
+ "version": "5.3.x-dev",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/web-auth/webauthn-lib.git",
+ "reference": "1ea7e2cae320f04c75212bf019e845d8d7faf950"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/1ea7e2cae320f04c75212bf019e845d8d7faf950",
+ "reference": "1ea7e2cae320f04c75212bf019e845d8d7faf950",
+ "shasum": ""
+ },
+ "require": {
+ "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": {
+ "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": {
+ "Webauthn\\": "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/webauthn-library/contributors"
+ }
+ ],
+ "description": "FIDO2/Webauthn Support For PHP",
+ "homepage": "https://github.com/web-auth",
+ "keywords": [
+ "FIDO2",
+ "fido",
+ "webauthn"
+ ],
+ "support": {
+ "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.x"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Spomky",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/FlorentMorselli",
+ "type": "patreon"
+ }
+ ],
+ "time": "2026-03-22T17:54:03+00:00"
+ },
{
"name": "webmozart/assert",
"version": "2.3.0",
diff --git a/config/passkeys.php b/config/passkeys.php
new file mode 100644
index 000000000..35084080d
--- /dev/null
+++ b/config/passkeys.php
@@ -0,0 +1,54 @@
+ '/',
+
+ /*
+ * 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' => env(
+ 'PASSKEY_RELYING_PARTY_ID',
+ parse_url(config('app.url', 'http://localhost'), PHP_URL_HOST) ?: 'localhost'
+ ),
+ '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/database/migrations/2026_04_24_000000_create_passkeys_table.php b/database/migrations/2026_04_24_000000_create_passkeys_table.php
new file mode 100644
index 000000000..8448e93c4
--- /dev/null
+++ b/database/migrations/2026_04_24_000000_create_passkeys_table.php
@@ -0,0 +1,41 @@
+getTable();
+
+ Schema::create('passkeys', function (Blueprint $table) use ($authenticatableTableName): void {
+ $table->id();
+ $table->unsignedInteger('authenticatable_id');
+ $table->foreign('authenticatable_id', 'passkeys_authenticatable_fk')
+ ->references('id')
+ ->on($authenticatableTableName)
+ ->cascadeOnDelete();
+
+ $table->text('name');
+ $table->text('credential_id');
+ $table->json('data');
+ $table->timestamp('last_used_at')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('passkeys');
+ }
+};
diff --git a/package.json b/package.json
index 2e4010c5d..3861026b1 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
"@fortawesome/fontawesome-free": "^7.2.0",
"@melloware/coloris": "^0.25.0",
"@simonwep/pickr": "^1.9.1",
+ "@simplewebauthn/browser": "^13.3.0",
"chart.js": "^4.5.1",
"date-fns": "^4.1.0",
"feather-icons": "^4.29.2",
diff --git a/resources/js/alpine/components/admin-user-passkeys.js b/resources/js/alpine/components/admin-user-passkeys.js
new file mode 100644
index 000000000..45073e13e
--- /dev/null
+++ b/resources/js/alpine/components/admin-user-passkeys.js
@@ -0,0 +1,14 @@
+import Alpine from '@alpinejs/csp';
+
+Alpine.data('adminUserPasskeys', () => ({
+ showDangerZone: false,
+ wipeConfirm: '',
+
+ toggleDangerZone() {
+ this.showDangerZone = !this.showDangerZone;
+ },
+
+ canWipe() {
+ return this.wipeConfirm === 'WIPE';
+ },
+}));
diff --git a/resources/js/alpine/components/passkey-login.js b/resources/js/alpine/components/passkey-login.js
new file mode 100644
index 000000000..cbf9f9bff
--- /dev/null
+++ b/resources/js/alpine/components/passkey-login.js
@@ -0,0 +1,104 @@
+import Alpine from '@alpinejs/csp';
+
+Alpine.data('passkeyLogin', () => ({
+ supported: false,
+ busy: false,
+ error: '',
+ showCreateHint: false,
+
+ init() {
+ this.supported = typeof window.browserSupportsWebAuthn === 'function'
+ && window.browserSupportsWebAuthn();
+
+ // If backend already reported an invalid passkey login attempt,
+ // immediately show the "sign in first, then create passkey" guidance.
+ this.showCreateHint = this.$el.dataset.serverPasskeyError === '1';
+ },
+
+ async authenticate() {
+ this.error = '';
+ this.showCreateHint = false;
+ this.busy = true;
+
+ try {
+ this.copyCaptchaResponse();
+
+ const rawOptionsUrl = this.$el.dataset.optionsUrl;
+ const optionsUrl = (!rawOptionsUrl || rawOptionsUrl === 'undefined')
+ ? '/passkeys/authentication-options'
+ : rawOptionsUrl;
+ const optionsResponse = await fetch(optionsUrl, {
+ credentials: 'same-origin',
+ headers: {
+ 'Accept': 'application/json',
+ 'X-Requested-With': 'XMLHttpRequest',
+ },
+ });
+
+ if (!optionsResponse.ok) {
+ throw new Error('Unable to load passkey authentication options.');
+ }
+
+ const optionsJson = await optionsResponse.json();
+ const startAuthenticationResponse = await window.startAuthentication({
+ optionsJSON: optionsJson,
+ });
+
+ this.$refs.response.value = JSON.stringify(startAuthenticationResponse);
+ document.getElementById('passkey-login-form')?.submit();
+ } catch (error) {
+ this.error = error instanceof Error
+ ? error.message
+ : 'Passkey authentication failed.';
+
+ // Common browser errors when no passkey exists for this account/device.
+ if (error instanceof Error && ['NotAllowedError', 'InvalidStateError', 'SecurityError'].includes(error.name)) {
+ this.showCreateHint = true;
+ }
+
+ this.busy = false;
+ }
+ },
+
+ copyCaptchaResponse() {
+ const turnstileValue = this.getFieldValue('cf-turnstile-response');
+ const recaptchaValue = this.getFieldValue('g-recaptcha-response');
+
+ if (this.$refs.turnstileResponse) {
+ this.$refs.turnstileResponse.value = turnstileValue;
+ }
+
+ if (this.$refs.recaptchaResponse) {
+ this.$refs.recaptchaResponse.value = recaptchaValue;
+ }
+ },
+
+ getFieldValue(fieldName) {
+ const candidates = document.querySelectorAll(`[name="${fieldName}"]`);
+
+ for (const field of candidates) {
+ if (field === this.$refs.turnstileResponse || field === this.$refs.recaptchaResponse) {
+ continue;
+ }
+
+ if (typeof field.value === 'string' && field.value.trim() !== '') {
+ return field.value.trim();
+ }
+ }
+
+ return '';
+ },
+
+ focusPasswordLogin() {
+ const username = document.getElementById('username');
+ if (username) {
+ username.focus();
+ return;
+ }
+
+ const password = document.getElementById('password');
+ if (password) {
+ password.focus();
+ }
+ },
+}));
diff --git a/resources/js/alpine/components/passkey-manage.js b/resources/js/alpine/components/passkey-manage.js
new file mode 100644
index 000000000..d44075157
--- /dev/null
+++ b/resources/js/alpine/components/passkey-manage.js
@@ -0,0 +1,115 @@
+import Alpine from '@alpinejs/csp';
+
+Alpine.data('passkeyManage', () => ({
+ supported: false,
+ busy: false,
+ name: '',
+ error: '',
+ success: '',
+ passkeys: [],
+
+ init() {
+ this.supported = typeof window.browserSupportsWebAuthn === 'function'
+ && window.browserSupportsWebAuthn();
+
+ const rawPasskeys = this.$el.dataset.passkeys;
+ if (rawPasskeys) {
+ try {
+ this.passkeys = JSON.parse(rawPasskeys);
+ } catch (_error) {
+ this.passkeys = [];
+ }
+ }
+ },
+
+ async createPasskey() {
+ if (!this.supported) {
+ this.error = 'Your browser does not support passkeys.';
+ return;
+ }
+
+ this.busy = true;
+ this.error = '';
+ this.success = '';
+
+ try {
+ const optionsUrl = this.$el.dataset.optionsUrl || '/passkeys/register-options';
+ const storeUrl = this.$el.dataset.storeUrl || '/passkeys';
+
+ const optionsResponse = await window.axios.post(optionsUrl, {
+ name: this.name,
+ });
+ const options = optionsResponse.data?.options;
+
+ const registration = await window.startRegistration({ optionsJSON: options });
+
+ const storeResponse = await window.axios.post(storeUrl, {
+ name: this.name,
+ passkey: JSON.stringify(registration),
+ });
+
+ if (!storeResponse.data?.ok) {
+ throw new Error(storeResponse.data?.message || 'Could not store passkey.');
+ }
+
+ this.passkeys.unshift(storeResponse.data.passkey);
+ this.success = 'Passkey created successfully.';
+ this.name = '';
+ } catch (error) {
+ this.error = this.extractErrorMessage(error);
+ } finally {
+ this.busy = false;
+ }
+ },
+
+ async deletePasskey(passkeyId) {
+ if (!window.confirm('Delete this passkey? This device will no longer work for passkey login.')) {
+ return;
+ }
+
+ this.error = '';
+ this.success = '';
+
+ try {
+ const destroyUrl = `${this.$el.dataset.destroyBaseUrl}/${passkeyId}`;
+ const response = await window.axios.delete(destroyUrl);
+
+ if (!response.data?.ok) {
+ throw new Error('Could not delete passkey.');
+ }
+
+ this.passkeys = this.passkeys.filter((passkey) => passkey.id !== passkeyId);
+ this.success = 'Passkey deleted successfully.';
+ } catch (error) {
+ this.error = this.extractErrorMessage(error);
+ }
+ },
+
+ formatDate(value) {
+ if (!value) {
+ return 'Unknown';
+ }
+
+ return new Date(value).toLocaleString();
+ },
+
+ formatLastUsed(value) {
+ if (!value) {
+ return 'Not used yet';
+ }
+
+ return this.formatDate(value);
+ },
+
+ extractErrorMessage(error) {
+ if (error?.response?.data?.message) {
+ return error.response.data.message;
+ }
+
+ if (error instanceof Error) {
+ return error.message;
+ }
+
+ return 'Something went wrong. Please try again.';
+ },
+}));
diff --git a/resources/js/alpine/lazy-loader.js b/resources/js/alpine/lazy-loader.js
index 261601337..c5c3220fc 100644
--- a/resources/js/alpine/lazy-loader.js
+++ b/resources/js/alpine/lazy-loader.js
@@ -39,6 +39,9 @@ const lazyComponentMap = {
'releaseMultiOps': () => import('./components/cart-page.js'), // same file
'authPage': () => import('./components/auth-page.js'),
'otpInput': () => import('./components/auth-page.js'), // same file
+ 'passkeyLogin': () => import('./components/passkey-login.js'),
+ 'passkeyManage': () => import('./components/passkey-manage.js'),
+ 'adminUserPasskeys': () => import('./components/admin-user-passkeys.js'),
// --- Admin components (includes Chart.js — heavy) ---
'adminDashboard': () => import('./components/admin/dashboard.js'),
diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js
index 5f1390b01..3856182f6 100644
--- a/resources/js/bootstrap.js
+++ b/resources/js/bootstrap.js
@@ -1,4 +1,13 @@
import axios from 'axios';
+import {
+ browserSupportsWebAuthn,
+ startAuthentication,
+ startRegistration,
+} from '@simplewebauthn/browser';
+
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
+window.browserSupportsWebAuthn = browserSupportsWebAuthn;
+window.startAuthentication = startAuthentication;
+window.startRegistration = startRegistration;
diff --git a/resources/views/admin/users/edit.blade.php b/resources/views/admin/users/edit.blade.php
index e919fef66..4425333b4 100644
--- a/resources/views/admin/users/edit.blade.php
+++ b/resources/views/admin/users/edit.blade.php
@@ -606,6 +606,101 @@
@endif
+ @if(!is_array($user))
+
+
+
+ Passkeys (WebAuthn)
+
+
+ {{ $user->passkeys->count() }} registered
+
+
+
+ @if($user->passkeys->isEmpty())
+
+ This user has no registered passkeys. They can create one from their profile security settings.
+
+ @else
+
+
+
+
+ | Name |
+ Created |
+ Last used |
+ Actions |
+
+
+
+ @foreach($user->passkeys as $passkey)
+
+ | {{ $passkey->name }} |
+ {{ optional($passkey->created_at)->diffForHumans() ?? 'Unknown' }} |
+ {{ optional($passkey->last_used_at)->diffForHumans() ?? 'Not used yet' }} |
+
+
+ |
+
+ @endforeach
+
+
+
+ @endif
+
+
+
+
+
+
+ Remove all passkeys if this user lost or replaced their passkey device.
+
+
+
+
+
+
+ @endif
+
+
+ @include('partials.passkey-authenticate')
diff --git a/resources/views/partials/passkey-authenticate.blade.php b/resources/views/partials/passkey-authenticate.blade.php
new file mode 100644
index 000000000..7316ad245
--- /dev/null
+++ b/resources/views/partials/passkey-authenticate.blade.php
@@ -0,0 +1,73 @@
+
+
+
+ @if($message = session('authenticatePasskey::message'))
+
+ {{ $message }}
+
+ @endif
+
+
+
+
+
+
+
+
+
+ No passkey was found for this login on this device/browser.
+
+
+ Sign in with your password first. After login, go to your profile security settings and create a passkey.
+
+
+
+
+
diff --git a/resources/views/partials/passkeys-manage.blade.php b/resources/views/partials/passkeys-manage.blade.php
new file mode 100644
index 000000000..ff7c4a0a9
--- /dev/null
+++ b/resources/views/partials/passkeys-manage.blade.php
@@ -0,0 +1,100 @@
+@php
+ $passkeyPayload = $user->passkeys->map(function ($passkey) {
+ return [
+ 'id' => $passkey->id,
+ 'name' => $passkey->name,
+ 'created_at' => optional($passkey->created_at)?->toIso8601String(),
+ 'last_used_at' => optional($passkey->last_used_at)?->toIso8601String(),
+ ];
+ });
+@endphp
+
+
+
+ Register a passkey from your security key, browser, or password manager.
+
+
+
+
+ Your browser does not support passkeys.
+
+
+
+
+
+
+
+
+
+
+
+
+ No passkeys registered yet.
+
+
+
+
+
+
+
+
+ | Name |
+ Created |
+ Last used |
+ Actions |
+
+
+
+
+
+ |
+ |
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+
diff --git a/resources/views/profile/edit.blade.php b/resources/views/profile/edit.blade.php
index 9b139a1ce..d6094c80d 100644
--- a/resources/views/profile/edit.blade.php
+++ b/resources/views/profile/edit.blade.php
@@ -467,6 +467,15 @@
+
+
+
+ Passkeys
+
+
+ @include('partials.passkeys-manage')
+
+
@endsection
diff --git a/routes/web.php b/routes/web.php
index e68fe08da..5a1bdc7d2 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -51,6 +51,8 @@ use App\Http\Controllers\ApiHelpController;
use App\Http\Controllers\Auth\EmailVerificationController;
use App\Http\Controllers\Auth\ForgotPasswordController;
use App\Http\Controllers\Auth\LoginController;
+use App\Http\Controllers\Auth\PasskeyLoginController;
+use App\Http\Controllers\Auth\PasskeyManagementController;
use App\Http\Controllers\Auth\RegisterController;
use App\Http\Controllers\Auth\ResetPasswordController;
use App\Http\Controllers\BooksController;
@@ -83,6 +85,7 @@ use App\Http\Controllers\SearchSuggestController;
use App\Http\Controllers\SeriesController;
use App\Http\Controllers\StatusPageController;
use App\Http\Controllers\TermsController;
+use Spatie\LaravelPasskeys\Http\Controllers\GeneratePasskeyAuthenticationOptionsController;
// Serve cover images from storage - Must be public (no auth required)
Route::get('/covers/{type}/{filename}', [CoverController::class, 'show'])
@@ -108,6 +111,9 @@ Route::match(['GET', 'POST'], 'privacy-policy', [PrivacyPolicyController::class,
Route::get('login', [LoginController::class, 'showLoginForm'])->name('login');
Route::post('login', [LoginController::class, 'login'])->name('login.post');
Route::match(['GET', 'POST'], 'logout', [LoginController::class, 'logout'])->name('logout');
+Route::get('passkeys/authentication-options', GeneratePasskeyAuthenticationOptionsController::class)
+ ->name('passkeys.authentication_options');
+Route::post('passkeys/authenticate', PasskeyLoginController::class)->name('passkeys.login');
Route::get('2fa/verify', [PasswordSecurityController::class, 'getVerify2fa'])->name('2fa.verify');
Route::post('2fa/verify', [PasswordSecurityController::class, 'verify2fa'])->name('2fa.post');
@@ -123,7 +129,14 @@ Route::post('contact-us', [ContactUsController::class, 'contact']);
Route::get('status', [StatusPageController::class, 'showStatusPage'])->name('status');
-Route::middleware('isVerified')->group(function () {
+Route::middleware(['auth', 'isVerified'])->group(function () {
+ Route::post('passkeys/register-options', [PasskeyManagementController::class, 'options'])
+ ->name('passkeys.register_options');
+ Route::post('passkeys', [PasskeyManagementController::class, 'store'])
+ ->name('passkeys.store');
+ Route::delete('passkeys/{passkey}', [PasskeyManagementController::class, 'destroy'])
+ ->name('passkeys.destroy');
+
Route::match(['GET', 'POST'], 'resetpassword', [ResetPasswordController::class, 'reset'])->name('resetpassword');
Route::match(['GET', 'POST'], 'profile', [ProfileController::class, 'show'])->name('profile');
@@ -224,6 +237,8 @@ Route::middleware(['role:Admin', '2fa'])->prefix('admin')->group(function () {
Route::get('category-delete', [AdminCategoryController::class, 'destroy'])->name('admin.category-delete');
Route::get('user-list', [AdminUserController::class, 'index'])->name('admin.user-list');
Route::match(['GET', 'POST'], 'user-edit', [AdminUserController::class, 'edit'])->name('admin.user-edit');
+ Route::delete('user-passkey/{passkey}', [AdminUserController::class, 'destroyPasskey'])->name('admin.user-passkey.destroy');
+ Route::post('user-passkeys/wipe', [AdminUserController::class, 'wipePasskeys'])->name('admin.user-passkeys.wipe');
Route::post('user-delete', [AdminUserController::class, 'destroy'])->name('admin.user-delete');
Route::post('verify', [AdminUserController::class, 'verify'])->name('admin.verify');
Route::post('resendverification', [AdminUserController::class, 'resendVerification'])->name('admin.resend-verification');
diff --git a/tests/Feature/Auth/PasskeyAdminTest.php b/tests/Feature/Auth/PasskeyAdminTest.php
new file mode 100644
index 000000000..5cdd52b35
--- /dev/null
+++ b/tests/Feature/Auth/PasskeyAdminTest.php
@@ -0,0 +1,253 @@
+ 'sqlite',
+ 'database.connections.sqlite.database' => ':memory:',
+ 'app.key' => 'base64:'.base64_encode(random_bytes(32)),
+ 'session.driver' => 'array',
+ ]);
+
+ DB::purge();
+ DB::reconnect();
+
+ $this->createSchema();
+ $this->seedSettings();
+ app(PermissionRegistrar::class)->forgetCachedPermissions();
+ $this->withoutMiddleware(Google2FAMiddleware::class);
+ }
+
+ public function test_non_admin_cannot_delete_another_users_passkey(): void
+ {
+ $regularUser = $this->createUserWithRole('User');
+ $targetUser = $this->createUserWithRole('User');
+ $passkeyId = $this->createPasskey($targetUser->id);
+
+ $response = $this->actingAs($regularUser)->delete(route('admin.user-passkey.destroy', ['passkey' => $passkeyId]));
+
+ $response->assertForbidden();
+ $this->assertDatabaseHas('passkeys', ['id' => $passkeyId]);
+ }
+
+ public function test_admin_can_delete_single_passkey_for_another_user(): void
+ {
+ $admin = $this->createUserWithRole('Admin');
+ $targetUser = $this->createUserWithRole('User');
+ $passkeyId = $this->createPasskey($targetUser->id, 'Work Laptop');
+
+ $response = $this->actingAs($admin)->delete(route('admin.user-passkey.destroy', ['passkey' => $passkeyId]));
+
+ $response->assertRedirect('admin/user-edit?id='.$targetUser->id);
+ $this->assertDatabaseMissing('passkeys', ['id' => $passkeyId]);
+ }
+
+ public function test_admin_wipe_requires_wipe_confirmation_text(): void
+ {
+ $admin = $this->createUserWithRole('Admin');
+ $targetUser = $this->createUserWithRole('User');
+ $passkeyId = $this->createPasskey($targetUser->id);
+
+ $response = $this->actingAs($admin)->post(route('admin.user-passkeys.wipe'), [
+ 'user_id' => $targetUser->id,
+ 'confirmation' => 'NOPE',
+ ]);
+
+ $response->assertSessionHasErrors('confirmation');
+ $this->assertDatabaseHas('passkeys', ['id' => $passkeyId]);
+ }
+
+ public function test_admin_can_wipe_all_passkeys_for_target_user_only(): void
+ {
+ $admin = $this->createUserWithRole('Admin');
+ $targetUser = $this->createUserWithRole('User');
+ $otherUser = $this->createUserWithRole('User');
+ $targetPasskeyOne = $this->createPasskey($targetUser->id, 'Target A');
+ $targetPasskeyTwo = $this->createPasskey($targetUser->id, 'Target B');
+ $otherPasskey = $this->createPasskey($otherUser->id, 'Other');
+
+ $response = $this->actingAs($admin)->post(route('admin.user-passkeys.wipe'), [
+ 'user_id' => $targetUser->id,
+ 'confirmation' => 'WIPE',
+ ]);
+
+ $response->assertRedirect('admin/user-edit?id='.$targetUser->id);
+ $this->assertDatabaseMissing('passkeys', ['id' => $targetPasskeyOne]);
+ $this->assertDatabaseMissing('passkeys', ['id' => $targetPasskeyTwo]);
+ $this->assertDatabaseHas('passkeys', ['id' => $otherPasskey]);
+ }
+
+ private function createSchema(): void
+ {
+ Schema::create('settings', function (Blueprint $table): void {
+ $table->string('name')->primary();
+ $table->text('value')->nullable();
+ });
+
+ Schema::create('roles', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->string('name');
+ $table->string('guard_name');
+ $table->integer('rate_limit')->default(60);
+ $table->boolean('isdefault')->default(false);
+ $table->unsignedInteger('defaultinvites')->default(0);
+ $table->timestamps();
+ });
+
+ Schema::create('permissions', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->string('name');
+ $table->string('guard_name');
+ $table->timestamps();
+ });
+
+ Schema::create('users', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->string('username');
+ $table->string('email')->unique();
+ $table->string('password');
+ $table->unsignedInteger('roles_id')->default(1);
+ $table->integer('rate_limit')->default(60);
+ $table->string('api_token')->nullable();
+ $table->boolean('verified')->default(true);
+ $table->boolean('can_post')->default(true);
+ $table->timestamp('email_verified_at')->nullable();
+ $table->timestamp('lastlogin')->nullable();
+ $table->rememberToken();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ Schema::create('model_has_roles', function (Blueprint $table): void {
+ $table->unsignedInteger('role_id');
+ $table->string('model_type');
+ $table->unsignedInteger('model_id');
+ $table->primary(['role_id', 'model_id', 'model_type']);
+ });
+
+ Schema::create('model_has_permissions', function (Blueprint $table): void {
+ $table->unsignedInteger('permission_id');
+ $table->string('model_type');
+ $table->unsignedInteger('model_id');
+ $table->primary(['permission_id', 'model_id', 'model_type']);
+ });
+
+ Schema::create('role_has_permissions', function (Blueprint $table): void {
+ $table->unsignedInteger('permission_id');
+ $table->unsignedInteger('role_id');
+ $table->primary(['permission_id', 'role_id']);
+ });
+
+ Schema::create('user_activities', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->unsignedInteger('user_id')->nullable();
+ $table->string('username');
+ $table->string('activity_type', 50);
+ $table->text('description');
+ $table->json('metadata')->nullable();
+ $table->timestamp('created_at')->nullable();
+ });
+
+ Schema::create('root_categories', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->string('title')->default('');
+ $table->integer('status')->default(1);
+ $table->timestamps();
+ });
+
+ Schema::create('categories', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->string('title')->default('');
+ $table->unsignedInteger('root_categories_id')->nullable();
+ $table->integer('status')->default(1);
+ $table->timestamps();
+ });
+
+ Schema::create('user_excluded_categories', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->unsignedInteger('users_id');
+ $table->unsignedInteger('categories_id');
+ });
+
+ Schema::create('passkeys', function (Blueprint $table): void {
+ $table->id();
+ $table->unsignedInteger('authenticatable_id');
+ $table->text('name');
+ $table->text('credential_id');
+ $table->json('data');
+ $table->timestamp('last_used_at')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ private function seedSettings(): void
+ {
+ DB::table('settings')->insert([
+ ['name' => 'title', 'value' => 'NNTmux Test'],
+ ['name' => 'home_link', 'value' => '/'],
+ ['name' => 'categorizeforeign', 'value' => '0'],
+ ['name' => 'catwebdl', 'value' => '0'],
+ ]);
+
+ DB::table('root_categories')->insert([
+ 'id' => 1,
+ 'title' => 'General',
+ 'status' => 1,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ private function createUserWithRole(string $roleName): User
+ {
+ $role = Role::query()->firstOrCreate(
+ ['name' => $roleName, 'guard_name' => 'web'],
+ ['rate_limit' => 60, 'isdefault' => $roleName === 'User', 'defaultinvites' => 1]
+ );
+
+ $user = User::query()->create([
+ 'username' => strtolower($roleName).'_'.Str::random(8),
+ 'email' => Str::random(12).'@example.test',
+ 'password' => bcrypt('password'),
+ 'roles_id' => $role->id,
+ 'rate_limit' => 60,
+ 'api_token' => Str::random(32),
+ 'verified' => true,
+ 'email_verified_at' => now(),
+ 'lastlogin' => now(),
+ ]);
+ $user->assignRole($role);
+
+ return $user->fresh();
+ }
+
+ private function createPasskey(int $userId, string $name = 'Key'): int
+ {
+ return (int) DB::table('passkeys')->insertGetId([
+ 'authenticatable_id' => $userId,
+ 'name' => $name,
+ 'credential_id' => 'credential-'.Str::random(8),
+ 'data' => json_encode(['origin' => 'tests'], JSON_THROW_ON_ERROR),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+}
diff --git a/tests/Feature/Auth/PasskeyAuthenticationTest.php b/tests/Feature/Auth/PasskeyAuthenticationTest.php
new file mode 100644
index 000000000..132062641
--- /dev/null
+++ b/tests/Feature/Auth/PasskeyAuthenticationTest.php
@@ -0,0 +1,226 @@
+ 'sqlite',
+ 'database.connections.sqlite.database' => ':memory:',
+ 'app.key' => 'base64:'.base64_encode(random_bytes(32)),
+ 'session.driver' => 'array',
+ ]);
+
+ DB::purge();
+ DB::reconnect();
+
+ $this->createSchema();
+ $this->seedSettings();
+ app(PermissionRegistrar::class)->forgetCachedPermissions();
+ $this->withoutMiddleware(Google2FAMiddleware::class);
+ }
+
+ public function test_passkey_authentication_logs_user_in_and_sets_2fa_session_flag(): void
+ {
+ Event::fake([UserLoggedIn::class]);
+ $user = $this->createUser('passkey-user@example.test');
+
+ $passkey = new Passkey;
+ $passkey->setRawAttributes([
+ 'id' => 1,
+ 'authenticatable_id' => $user->id,
+ 'name' => 'Laptop',
+ 'credential_id' => 'credential-1',
+ 'data' => '{}',
+ ], true);
+ $passkey->setRelation('authenticatable', $user);
+
+ FakeFindPasskeyAction::$passkey = $passkey;
+ config()->set('passkeys.actions.find_passkey', FakeFindPasskeyAction::class);
+
+ $response = $this
+ ->withSession(['passkey-authentication-options' => '{}'])
+ ->post(route('passkeys.login'), [
+ 'start_authentication_response' => json_encode(['id' => 'credential-1'], JSON_THROW_ON_ERROR),
+ 'remember' => false,
+ ]);
+
+ $response->assertRedirect('/');
+ $this->assertAuthenticatedAs($user);
+ $this->assertTrue((bool) session(config('google2fa.session_var')));
+ Event::assertDispatched(UserLoggedIn::class);
+ }
+
+ public function test_unverified_users_cannot_authenticate_with_passkeys(): void
+ {
+ $user = $this->createUser('unverified@example.test', false);
+ $passkey = new Passkey;
+ $passkey->setRawAttributes([
+ 'id' => 2,
+ 'authenticatable_id' => $user->id,
+ 'name' => 'Phone',
+ 'credential_id' => 'credential-2',
+ 'data' => '{}',
+ ], true);
+ $passkey->setRelation('authenticatable', $user);
+
+ FakeFindPasskeyAction::$passkey = $passkey;
+ config()->set('passkeys.actions.find_passkey', FakeFindPasskeyAction::class);
+
+ $response = $this
+ ->from(route('login'))
+ ->withSession(['passkey-authentication-options' => '{}'])
+ ->post(route('passkeys.login'), [
+ 'start_authentication_response' => json_encode(['id' => 'credential-2'], JSON_THROW_ON_ERROR),
+ ]);
+
+ $response->assertRedirect(route('login'));
+ $this->assertGuest();
+ $this->assertSame('You have not verified your email address!', session('authenticatePasskey::message'));
+ }
+
+ private function createSchema(): void
+ {
+ Schema::create('settings', function (Blueprint $table): void {
+ $table->string('name')->primary();
+ $table->text('value')->nullable();
+ });
+
+ Schema::create('roles', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->string('name');
+ $table->string('guard_name');
+ $table->integer('rate_limit')->default(60);
+ $table->boolean('isdefault')->default(false);
+ $table->unsignedInteger('defaultinvites')->default(0);
+ $table->timestamps();
+ });
+
+ Schema::create('permissions', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->string('name');
+ $table->string('guard_name');
+ $table->timestamps();
+ });
+
+ Schema::create('users', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->string('username');
+ $table->string('email')->unique();
+ $table->string('password');
+ $table->unsignedInteger('roles_id')->default(1);
+ $table->integer('rate_limit')->default(60);
+ $table->string('api_token')->nullable();
+ $table->boolean('verified')->default(true);
+ $table->boolean('can_post')->default(true);
+ $table->timestamp('email_verified_at')->nullable();
+ $table->timestamp('lastlogin')->nullable();
+ $table->rememberToken();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ Schema::create('model_has_roles', function (Blueprint $table): void {
+ $table->unsignedInteger('role_id');
+ $table->string('model_type');
+ $table->unsignedInteger('model_id');
+ $table->primary(['role_id', 'model_id', 'model_type']);
+ });
+
+ Schema::create('model_has_permissions', function (Blueprint $table): void {
+ $table->unsignedInteger('permission_id');
+ $table->string('model_type');
+ $table->unsignedInteger('model_id');
+ $table->primary(['permission_id', 'model_id', 'model_type']);
+ });
+
+ Schema::create('role_has_permissions', function (Blueprint $table): void {
+ $table->unsignedInteger('permission_id');
+ $table->unsignedInteger('role_id');
+ $table->primary(['permission_id', 'role_id']);
+ });
+
+ Schema::create('user_activities', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->unsignedInteger('user_id')->nullable();
+ $table->string('username');
+ $table->string('activity_type', 50);
+ $table->text('description');
+ $table->json('metadata')->nullable();
+ $table->timestamp('created_at')->nullable();
+ });
+
+ Schema::create('passkeys', function (Blueprint $table): void {
+ $table->id();
+ $table->unsignedInteger('authenticatable_id');
+ $table->text('name');
+ $table->text('credential_id');
+ $table->json('data');
+ $table->timestamp('last_used_at')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ private function seedSettings(): void
+ {
+ DB::table('settings')->insert([
+ ['name' => 'title', 'value' => 'NNTmux Test'],
+ ['name' => 'home_link', 'value' => '/'],
+ ['name' => 'categorizeforeign', 'value' => '0'],
+ ['name' => 'catwebdl', 'value' => '0'],
+ ]);
+ }
+
+ private function createUser(string $email, bool $verified = true): User
+ {
+ $role = Role::query()->firstOrCreate(
+ ['name' => 'User', 'guard_name' => 'web'],
+ ['rate_limit' => 60, 'isdefault' => true, 'defaultinvites' => 1]
+ );
+
+ $user = User::query()->create([
+ 'username' => 'user_'.md5($email),
+ 'email' => $email,
+ 'password' => bcrypt('password'),
+ 'roles_id' => $role->id,
+ 'rate_limit' => 60,
+ 'api_token' => md5($email),
+ 'verified' => $verified,
+ 'email_verified_at' => $verified ? now() : null,
+ 'lastlogin' => now(),
+ ]);
+
+ $user->assignRole($role);
+
+ return $user->fresh();
+ }
+}
+
+class FakeFindPasskeyAction extends FindPasskeyToAuthenticateAction
+{
+ public static ?Passkey $passkey = null;
+
+ public function execute(string $publicKeyCredentialJson, string $passkeyOptionsJson): ?Passkey
+ {
+ return self::$passkey;
+ }
+}
diff --git a/tests/Feature/Auth/PasskeyManagementTest.php b/tests/Feature/Auth/PasskeyManagementTest.php
new file mode 100644
index 000000000..1a977fcda
--- /dev/null
+++ b/tests/Feature/Auth/PasskeyManagementTest.php
@@ -0,0 +1,230 @@
+ 'sqlite',
+ 'database.connections.sqlite.database' => ':memory:',
+ 'app.key' => 'base64:'.base64_encode(random_bytes(32)),
+ 'session.driver' => 'array',
+ ]);
+
+ DB::purge();
+ DB::reconnect();
+
+ $this->createSchema();
+ $this->seedSettings();
+ app(PermissionRegistrar::class)->forgetCachedPermissions();
+ $this->withoutMiddleware(Google2FAMiddleware::class);
+
+ config()->set('passkeys.actions.generate_passkey_register_options', FakeGeneratePasskeyRegisterOptionsAction::class);
+ config()->set('passkeys.actions.store_passkey', FakeStorePasskeyAction::class);
+ }
+
+ public function test_verified_user_can_generate_options_store_and_delete_a_passkey(): void
+ {
+ $user = $this->createUser('passkey-manage@example.test');
+
+ $optionsResponse = $this
+ ->actingAs($user)
+ ->postJson(route('passkeys.register_options'), ['name' => 'MacBook']);
+ $optionsResponse->assertOk()->assertJsonPath('ok', true);
+
+ $storeResponse = $this
+ ->actingAs($user)
+ ->withSession([
+ 'passkey-registration-options' => '{}',
+ ])
+ ->postJson(route('passkeys.store'), [
+ 'name' => 'MacBook',
+ 'passkey' => json_encode(['id' => 'credential-1'], JSON_THROW_ON_ERROR),
+ ]);
+
+ $storeResponse->assertOk()->assertJsonPath('ok', true);
+ $passkeyId = (int) $storeResponse->json('passkey.id');
+ $this->assertDatabaseHas('passkeys', ['id' => $passkeyId, 'authenticatable_id' => $user->id]);
+
+ $deleteResponse = $this
+ ->actingAs($user)
+ ->deleteJson(route('passkeys.destroy', ['passkey' => $passkeyId]));
+
+ $deleteResponse->assertOk()->assertJson(['ok' => true]);
+ $this->assertDatabaseMissing('passkeys', ['id' => $passkeyId]);
+ }
+
+ public function test_guest_cannot_hit_passkey_management_routes(): void
+ {
+ $this->post(route('passkeys.register_options'), ['name' => 'Guest key'])->assertRedirect(route('login'));
+ $this->post(route('passkeys.store'), ['name' => 'Guest key', 'passkey' => '{}'])->assertRedirect(route('login'));
+ }
+
+ private function createSchema(): void
+ {
+ Schema::create('settings', function (Blueprint $table): void {
+ $table->string('name')->primary();
+ $table->text('value')->nullable();
+ });
+
+ Schema::create('roles', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->string('name');
+ $table->string('guard_name');
+ $table->integer('rate_limit')->default(60);
+ $table->boolean('isdefault')->default(false);
+ $table->unsignedInteger('defaultinvites')->default(0);
+ $table->timestamps();
+ });
+
+ Schema::create('permissions', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->string('name');
+ $table->string('guard_name');
+ $table->timestamps();
+ });
+
+ Schema::create('users', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->string('username');
+ $table->string('email')->unique();
+ $table->string('password');
+ $table->unsignedInteger('roles_id')->default(1);
+ $table->integer('rate_limit')->default(60);
+ $table->string('api_token')->nullable();
+ $table->boolean('verified')->default(true);
+ $table->boolean('can_post')->default(true);
+ $table->timestamp('email_verified_at')->nullable();
+ $table->timestamp('lastlogin')->nullable();
+ $table->rememberToken();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ Schema::create('model_has_roles', function (Blueprint $table): void {
+ $table->unsignedInteger('role_id');
+ $table->string('model_type');
+ $table->unsignedInteger('model_id');
+ $table->primary(['role_id', 'model_id', 'model_type']);
+ });
+
+ Schema::create('model_has_permissions', function (Blueprint $table): void {
+ $table->unsignedInteger('permission_id');
+ $table->string('model_type');
+ $table->unsignedInteger('model_id');
+ $table->primary(['permission_id', 'model_id', 'model_type']);
+ });
+
+ Schema::create('role_has_permissions', function (Blueprint $table): void {
+ $table->unsignedInteger('permission_id');
+ $table->unsignedInteger('role_id');
+ $table->primary(['permission_id', 'role_id']);
+ });
+
+ Schema::create('user_activities', function (Blueprint $table): void {
+ $table->increments('id');
+ $table->unsignedInteger('user_id')->nullable();
+ $table->string('username');
+ $table->string('activity_type', 50);
+ $table->text('description');
+ $table->json('metadata')->nullable();
+ $table->timestamp('created_at')->nullable();
+ });
+
+ Schema::create('passkeys', function (Blueprint $table): void {
+ $table->id();
+ $table->unsignedInteger('authenticatable_id');
+ $table->text('name');
+ $table->text('credential_id');
+ $table->json('data');
+ $table->timestamp('last_used_at')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ private function seedSettings(): void
+ {
+ DB::table('settings')->insert([
+ ['name' => 'title', 'value' => 'NNTmux Test'],
+ ['name' => 'home_link', 'value' => '/'],
+ ['name' => 'categorizeforeign', 'value' => '0'],
+ ['name' => 'catwebdl', 'value' => '0'],
+ ]);
+ }
+
+ private function createUser(string $email): User
+ {
+ $role = Role::query()->firstOrCreate(
+ ['name' => 'User', 'guard_name' => 'web'],
+ ['rate_limit' => 60, 'isdefault' => true, 'defaultinvites' => 1]
+ );
+
+ $user = User::query()->create([
+ 'username' => 'user_'.md5($email),
+ 'email' => $email,
+ 'password' => bcrypt('password'),
+ 'roles_id' => $role->id,
+ 'rate_limit' => 60,
+ 'api_token' => md5($email),
+ 'verified' => true,
+ 'email_verified_at' => now(),
+ 'lastlogin' => now(),
+ ]);
+ $user->assignRole($role);
+
+ return $user->fresh();
+ }
+}
+
+class FakeGeneratePasskeyRegisterOptionsAction extends GeneratePasskeyRegisterOptionsAction
+{
+ public function execute(HasPasskeys $authenticatable, bool $asJson = true): string
+ {
+ return json_encode([
+ 'challenge' => 'test-challenge',
+ 'rp' => ['name' => 'NNTmux'],
+ 'user' => ['id' => (string) $authenticatable->id],
+ ], JSON_THROW_ON_ERROR);
+ }
+}
+
+class FakeStorePasskeyAction extends StorePasskeyAction
+{
+ public function execute(
+ HasPasskeys $authenticatable,
+ string $passkeyJson,
+ string $passkeyOptionsJson,
+ string $hostName,
+ array $additionalProperties = []
+ ): Passkey {
+ DB::table('passkeys')->insertGetId([
+ 'authenticatable_id' => $authenticatable->id,
+ 'name' => $additionalProperties['name'] ?? 'Unnamed',
+ 'credential_id' => 'credential-'.md5($passkeyJson),
+ 'data' => json_encode(['host' => $hostName], JSON_THROW_ON_ERROR),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ return Passkey::query()->where('authenticatable_id', $authenticatable->id)->latest('id')->firstOrFail();
+ }
+}
diff --git a/tests/Feature/Auth/RelyingPartyIdResolverTest.php b/tests/Feature/Auth/RelyingPartyIdResolverTest.php
new file mode 100644
index 000000000..b71d3f6f6
--- /dev/null
+++ b/tests/Feature/Auth/RelyingPartyIdResolverTest.php
@@ -0,0 +1,50 @@
+set('passkeys.relying_party.id', 'example.com');
+
+ $request = Request::create('http://127.0.0.1/passkeys/authentication-options', 'GET', [], [], [], [
+ 'HTTP_X_FORWARDED_HOST' => 'auth.example.com',
+ ]);
+
+ $this->assertSame('auth.example.com', RelyingPartyIdResolver::resolve($request));
+ }
+
+ public function test_prefers_configured_domain_over_localhost_request_host(): void
+ {
+ config()->set('passkeys.relying_party.id', 'example.com');
+
+ $request = Request::create('http://localhost/passkeys/authentication-options', 'GET');
+
+ $this->assertSame('example.com', RelyingPartyIdResolver::resolve($request));
+ }
+
+ public function test_rejects_ip_hosts_and_falls_back_to_localhost(): void
+ {
+ config()->set('passkeys.relying_party.id', '127.0.0.1');
+
+ $request = Request::create('http://127.0.0.1/passkeys/authentication-options', 'GET');
+
+ $this->assertSame('localhost', RelyingPartyIdResolver::resolve($request));
+ }
+
+ public function test_allows_localhost_for_development(): void
+ {
+ config()->set('passkeys.relying_party.id', 'localhost');
+
+ $request = Request::create('http://localhost/passkeys/authentication-options', 'GET');
+
+ $this->assertSame('localhost', RelyingPartyIdResolver::resolve($request));
+ }
+}