From 4078db83404d0d7a86fcceba96c83f4d5b508824 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:02:04 +0000 Subject: [PATCH 01/33] Tighten Fortify controller return types laravel/fortify PR 654 corrected the docblock return type of EmailVerificationNotificationController::store(). Hypervel declared that method, EmailVerificationPromptController::__invoke() and TwoFactorAuthenticatedSessionController::store() as returning mixed, although each returns a known set of types. They now declare JsonResponse|Responsable, Responsable and Response|TwoFactorLoginResponse respectively. The constructor title docblocks that upstream carries in five controllers are restored. Upstream reference: laravel/fortify 1.x at c456642584. Validation: the Fortify suite, formatting and PHPStan pass. --- .../src/Http/Controllers/AuthenticatedSessionController.php | 3 +++ .../src/Http/Controllers/ConfirmablePasswordController.php | 3 +++ .../Controllers/EmailVerificationNotificationController.php | 3 ++- .../Http/Controllers/EmailVerificationPromptController.php | 3 ++- src/fortify/src/Http/Controllers/NewPasswordController.php | 3 +++ .../src/Http/Controllers/RegisteredUserController.php | 3 +++ .../Controllers/TwoFactorAuthenticatedSessionController.php | 6 +++++- 7 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/fortify/src/Http/Controllers/AuthenticatedSessionController.php b/src/fortify/src/Http/Controllers/AuthenticatedSessionController.php index d254fa02f4..f500c1bd08 100644 --- a/src/fortify/src/Http/Controllers/AuthenticatedSessionController.php +++ b/src/fortify/src/Http/Controllers/AuthenticatedSessionController.php @@ -23,6 +23,9 @@ class AuthenticatedSessionController extends Controller { + /** + * Create a new controller instance. + */ public function __construct( private readonly Container $container, private readonly Config $config, diff --git a/src/fortify/src/Http/Controllers/ConfirmablePasswordController.php b/src/fortify/src/Http/Controllers/ConfirmablePasswordController.php index eeab15fdce..7901d40e29 100644 --- a/src/fortify/src/Http/Controllers/ConfirmablePasswordController.php +++ b/src/fortify/src/Http/Controllers/ConfirmablePasswordController.php @@ -18,6 +18,9 @@ class ConfirmablePasswordController extends Controller { + /** + * Create a new controller instance. + */ public function __construct( private readonly Container $container, ) { diff --git a/src/fortify/src/Http/Controllers/EmailVerificationNotificationController.php b/src/fortify/src/Http/Controllers/EmailVerificationNotificationController.php index caa515c99d..fc3f8e8b2f 100644 --- a/src/fortify/src/Http/Controllers/EmailVerificationNotificationController.php +++ b/src/fortify/src/Http/Controllers/EmailVerificationNotificationController.php @@ -5,6 +5,7 @@ namespace Hypervel\Fortify\Http\Controllers; use Hypervel\Contracts\Auth\MustVerifyEmail; +use Hypervel\Contracts\Support\Responsable; use Hypervel\Fortify\Contracts\EmailVerificationNotificationSentResponse; use Hypervel\Fortify\Http\Responses\RedirectAsIntended; use Hypervel\Http\JsonResponse; @@ -16,7 +17,7 @@ class EmailVerificationNotificationController extends Controller /** * Send a new email verification notification. */ - public function store(Request $request): mixed + public function store(Request $request): JsonResponse|Responsable { /** @var MustVerifyEmail $user */ $user = $request->user(); diff --git a/src/fortify/src/Http/Controllers/EmailVerificationPromptController.php b/src/fortify/src/Http/Controllers/EmailVerificationPromptController.php index 27aafc748b..5d39f84c4b 100644 --- a/src/fortify/src/Http/Controllers/EmailVerificationPromptController.php +++ b/src/fortify/src/Http/Controllers/EmailVerificationPromptController.php @@ -5,6 +5,7 @@ namespace Hypervel\Fortify\Http\Controllers; use Hypervel\Contracts\Auth\MustVerifyEmail; +use Hypervel\Contracts\Support\Responsable; use Hypervel\Fortify\Contracts\VerifyEmailViewResponse; use Hypervel\Fortify\Http\Responses\RedirectAsIntended; use Hypervel\Http\Request; @@ -15,7 +16,7 @@ class EmailVerificationPromptController extends Controller /** * Display the email verification prompt. */ - public function __invoke(Request $request): mixed + public function __invoke(Request $request): Responsable { /** @var MustVerifyEmail $user */ $user = $request->user(); diff --git a/src/fortify/src/Http/Controllers/NewPasswordController.php b/src/fortify/src/Http/Controllers/NewPasswordController.php index 365fad7218..25d0d650fd 100644 --- a/src/fortify/src/Http/Controllers/NewPasswordController.php +++ b/src/fortify/src/Http/Controllers/NewPasswordController.php @@ -25,6 +25,9 @@ class NewPasswordController extends Controller { + /** + * Create a new controller instance. + */ public function __construct( private readonly Container $container, private readonly Config $config, diff --git a/src/fortify/src/Http/Controllers/RegisteredUserController.php b/src/fortify/src/Http/Controllers/RegisteredUserController.php index f6d2e937ff..5c874aa222 100644 --- a/src/fortify/src/Http/Controllers/RegisteredUserController.php +++ b/src/fortify/src/Http/Controllers/RegisteredUserController.php @@ -20,6 +20,9 @@ class RegisteredUserController extends Controller { use DispatchesEvents; + /** + * Create a new controller instance. + */ public function __construct( private readonly Container $container, private readonly Config $config, diff --git a/src/fortify/src/Http/Controllers/TwoFactorAuthenticatedSessionController.php b/src/fortify/src/Http/Controllers/TwoFactorAuthenticatedSessionController.php index 1f4e2f2d0c..9c210692a2 100644 --- a/src/fortify/src/Http/Controllers/TwoFactorAuthenticatedSessionController.php +++ b/src/fortify/src/Http/Controllers/TwoFactorAuthenticatedSessionController.php @@ -18,11 +18,15 @@ use Hypervel\Fortify\Http\Requests\TwoFactorLoginRequest; use Hypervel\Http\Exceptions\HttpResponseException; use Hypervel\Routing\Controller; +use Symfony\Component\HttpFoundation\Response; class TwoFactorAuthenticatedSessionController extends Controller { use DispatchesEvents; + /** + * Create a new controller instance. + */ public function __construct( private readonly Container $container, ) { @@ -43,7 +47,7 @@ public function create(TwoFactorLoginRequest $request): TwoFactorChallengeViewRe /** * Attempt to authenticate a new session using the two factor authentication code. */ - public function store(TwoFactorLoginRequest $request): mixed + public function store(TwoFactorLoginRequest $request): Response|TwoFactorLoginResponse { /** @var Authenticatable&Model&TwoFactorAuthenticationUser $user */ $user = $request->challengedUser(); From 11fdf22d4cd5bcecc6f6c972bf856107b492a3e1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:02:07 +0000 Subject: [PATCH 02/33] Remove inaccurate comment from the password rules stub While reconciling laravel/fortify PR 659, which made the stub imports consistent, the PasswordValidationRules stub turned out to carry a comment claiming Hypervel uses the framework password rule instead of Fortify's deprecated wrapper. Upstream's stub uses the framework rule too, so the comment described no difference and was published into every application that installed Fortify. The stub already imports Rule, so PR 659 needs no other change. Upstream reference: laravel/fortify 1.x at c456642584. Validation: the Fortify suite passes. --- src/fortify/stubs/PasswordValidationRules.stub | 1 - 1 file changed, 1 deletion(-) diff --git a/src/fortify/stubs/PasswordValidationRules.stub b/src/fortify/stubs/PasswordValidationRules.stub index 191212465d..a1be80fd5a 100644 --- a/src/fortify/stubs/PasswordValidationRules.stub +++ b/src/fortify/stubs/PasswordValidationRules.stub @@ -16,7 +16,6 @@ trait PasswordValidationRules */ protected function passwordRules(): array { - // Hypervel uses the framework password rule directly instead of Laravel Fortify's deprecated compatibility wrapper. return ['required', 'string', Password::default(), 'confirmed']; } } From 6079e05d7870335f84cadaec81908d9b79810f88 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:02:11 +0000 Subject: [PATCH 03/33] Test that Fortify responses implement their bound contracts laravel/fortify PR 701 fixed TwoFactorEnabledResponse and TwoFactorDisabledResponse implementing the wrong interface and added a test covering every response binding. Hypervel's responses already implement their own contracts; the upstream ResponseBindingTest is ported to cover all twenty bindings registered by the service provider. Upstream reference: laravel/fortify 1.x at c456642584. Validation: the Fortify suite and formatting pass. --- tests/Fortify/ResponseBindingTest.php | 56 +++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 tests/Fortify/ResponseBindingTest.php diff --git a/tests/Fortify/ResponseBindingTest.php b/tests/Fortify/ResponseBindingTest.php new file mode 100644 index 0000000000..bee2cb8f53 --- /dev/null +++ b/tests/Fortify/ResponseBindingTest.php @@ -0,0 +1,56 @@ +assertTrue( + is_a($response, $contract, true), + "The [{$response}] class should implement the [{$contract}] contract." + ); + } + + /** + * The response bindings registered by the Fortify service provider. + * + * Mirrors {@see FortifyServiceProvider::registerResponseBindings()}. + * + * @return array + */ + public static function responseBindingsProvider(): array + { + return [ + 'EmailVerificationNotificationSentResponse' => [Contracts\EmailVerificationNotificationSentResponse::class, Responses\EmailVerificationNotificationSentResponse::class], + 'FailedPasswordConfirmationResponse' => [Contracts\FailedPasswordConfirmationResponse::class, Responses\FailedPasswordConfirmationResponse::class], + 'FailedPasswordResetLinkRequestResponse' => [Contracts\FailedPasswordResetLinkRequestResponse::class, Responses\FailedPasswordResetLinkRequestResponse::class], + 'FailedPasswordResetResponse' => [Contracts\FailedPasswordResetResponse::class, Responses\FailedPasswordResetResponse::class], + 'FailedTwoFactorLoginResponse' => [Contracts\FailedTwoFactorLoginResponse::class, Responses\FailedTwoFactorLoginResponse::class], + 'LockoutResponse' => [Contracts\LockoutResponse::class, Responses\LockoutResponse::class], + 'LoginResponse' => [Contracts\LoginResponse::class, Responses\LoginResponse::class], + 'LogoutResponse' => [Contracts\LogoutResponse::class, Responses\LogoutResponse::class], + 'PasswordConfirmedResponse' => [Contracts\PasswordConfirmedResponse::class, Responses\PasswordConfirmedResponse::class], + 'PasswordResetResponse' => [Contracts\PasswordResetResponse::class, Responses\PasswordResetResponse::class], + 'PasswordUpdateResponse' => [Contracts\PasswordUpdateResponse::class, Responses\PasswordUpdateResponse::class], + 'ProfileInformationUpdatedResponse' => [Contracts\ProfileInformationUpdatedResponse::class, Responses\ProfileInformationUpdatedResponse::class], + 'RecoveryCodesGeneratedResponse' => [Contracts\RecoveryCodesGeneratedResponse::class, Responses\RecoveryCodesGeneratedResponse::class], + 'RegisterResponse' => [Contracts\RegisterResponse::class, Responses\RegisterResponse::class], + 'SuccessfulPasswordResetLinkRequestResponse' => [Contracts\SuccessfulPasswordResetLinkRequestResponse::class, Responses\SuccessfulPasswordResetLinkRequestResponse::class], + 'TwoFactorConfirmedResponse' => [Contracts\TwoFactorConfirmedResponse::class, Responses\TwoFactorConfirmedResponse::class], + 'TwoFactorDisabledResponse' => [Contracts\TwoFactorDisabledResponse::class, Responses\TwoFactorDisabledResponse::class], + 'TwoFactorEnabledResponse' => [Contracts\TwoFactorEnabledResponse::class, Responses\TwoFactorEnabledResponse::class], + 'TwoFactorLoginResponse' => [Contracts\TwoFactorLoginResponse::class, Responses\TwoFactorLoginResponse::class], + 'VerifyEmailResponse' => [Contracts\VerifyEmailResponse::class, Responses\VerifyEmailResponse::class], + ]; + } +} From 69b874cdbf3c2dda673c69334f4179d4c89fb71d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:02:14 +0000 Subject: [PATCH 04/33] Allow customizing Fortify recovery code generation laravel/fortify PR 694 lets applications replace the recovery code generator. Fortify::generateRecoveryCodesUsing() registers a callback that RecoveryCode::generate() invokes for each code. As with Fortify's other boot-time callbacks, Hypervel keeps it in a private static property with a getter and flushState() reset instead of upstream's public static property, and the README's private statics entry lists the new method. The upstream test is ported, and the user documentation gains a recovery code section, since upstream documents the option nowhere. The Fortify documentation also ports the passkey material from the 13.x docs for laravel/fortify PR 668: JavaScript client usage with per-call route overrides, the framework helpers, the redirects that standard requests receive, and passkey confirmation satisfying the current guard's password.confirm middleware. The provider test's narrower check of the two-factor response bindings is removed, as the previous commit's ResponseBindingTest covers every binding. Upstream reference: laravel/fortify 1.x at c456642584; laravel/docs 13.x at 9c2295fc4b. Validation: the Fortify suite, formatting and PHPStan pass. --- src/docs/fortify.md | 37 +++++++++++++++++++- src/fortify/README.md | 2 +- src/fortify/src/Fortify.php | 21 +++++++++++ src/fortify/src/RecoveryCode.php | 4 +++ tests/Fortify/FortifyApiTest.php | 1 + tests/Fortify/FortifyServiceProviderTest.php | 25 +++++-------- 6 files changed, 71 insertions(+), 19 deletions(-) diff --git a/src/docs/fortify.md b/src/docs/fortify.md index 71e452ebb4..331828925e 100644 --- a/src/docs/fortify.md +++ b/src/docs/fortify.md @@ -437,6 +437,18 @@ The built-in two-factor routes include: Fortify stores recovery codes as one encrypted JSON value. When a recovery code is used, Hypervel Fortify replaces the exact decoded JSON array entry and re-encrypts the whole JSON value. +You may customize how recovery codes are generated by registering a callback in the `boot` method of your application's `App\Providers\FortifyServiceProvider` class. Fortify invokes the callback once for each recovery code it generates: + +```php +use Hypervel\Fortify\Fortify; +use Hypervel\Support\Str; + +public function boot(): void +{ + Fortify::generateRecoveryCodesUsing(fn (): string => Str::upper(Str::random(16))); +} +``` + The two-factor provider defaults to 32-character TOTP secrets. The optional `window` feature option is step-based: a value of `1` accepts the previous, current, and next 30-second periods. Accepted TOTP codes are cached for the full accepted window to prevent replay for as long as Fortify still accepts the code. Hypervel Fortify uses fresh OTPHP TOTP objects with an injected clock, so verification does not mutate shared TOTP engine state in a Swoole worker. During login, users with enabled two-factor authentication are redirected to the two-factor challenge route. JSON login requests receive a response containing a `two_factor` boolean. The challenge form should submit either a `code` field containing a TOTP code or a `recovery_code` field containing one of the user's recovery codes to `POST /two-factor-challenge`. @@ -509,6 +521,15 @@ Hypervel's passkey routes are compatible with the `@laravel/passkeys` frontend p npm install @laravel/passkeys ``` +Then, you may initiate passkey registration and verification from your frontend: + +```js +import { Passkeys } from '@laravel/passkeys'; + +await Passkeys.register({ name: 'MacBook Pro' }); +await Passkeys.verify(); +``` + The frontend package defaults to these backend endpoints:
@@ -520,7 +541,7 @@ The frontend package defaults to these backend endpoints:
-It also supports route overrides: +If your application uses custom passkey endpoint URIs, you may override the routes on a per-call basis: ```js await Passkeys.verify({ @@ -529,8 +550,18 @@ await Passkeys.verify({ submit: '/passkeys/confirm', }, }); + +await Passkeys.register({ + name: 'MacBook Pro', + routes: { + options: '/user/passkeys/options', + submit: '/user/passkeys', + }, +}); ``` +The package also provides React, Vue, and Svelte helpers via `@laravel/passkeys/react`, `@laravel/passkeys/vue`, and `@laravel/passkeys/svelte`. + ### Request And Response Contracts @@ -548,6 +579,10 @@ Custom passkey frontends should use JSON requests and preserve the normal Hyperv +Standard requests receive redirects instead. Login and confirmation redirect to the intended destination, while registration and deletion redirect back with a `passkey-registered` or `passkey-deleted` status in the session. + +A successful passkey confirmation marks the session as password confirmed for the current guard, so it satisfies that guard's `password.confirm` middleware. + ### Customizing Passkeys diff --git a/src/fortify/README.md b/src/fortify/README.md index 3a7e5f9c79..2ed979f666 100644 --- a/src/fortify/README.md +++ b/src/fortify/README.md @@ -15,6 +15,6 @@ Documentation: https://hypervel.org/docs/fortify - Fortify's two-factor provider uses OTPHP instead of Google2FA and generates 32-character secrets by default. - Two-factor user models implement the `TwoFactorAuthenticationUser` contract as well as using the `TwoFactorAuthenticatable` trait. Recovery codes are consumed atomically through the user's `consumeRecoveryCode()` method; `TwoFactorLoginRequest::validRecoveryCode()` only checks a code and leaves the login challenge in the session. - Fortify omits Laravel's deprecated `Rules\Password`. -- Fortify keeps Laravel's directly writable static configuration properties private so worker-lifetime state remains typed and resettable. Use `authenticateThrough()`, `authenticateUsing()`, `confirmPasswordsUsing()`, `encryptUsing()`, and `ignoreRoutes()` instead. +- Fortify keeps Laravel's directly writable static configuration properties private so worker-lifetime state remains typed and resettable. Use `authenticateThrough()`, `authenticateUsing()`, `confirmPasswordsUsing()`, `generateRecoveryCodesUsing()`, `encryptUsing()`, and `ignoreRoutes()` instead. Ported from: https://github.com/laravel/fortify diff --git a/src/fortify/src/Fortify.php b/src/fortify/src/Fortify.php index 3574d33989..d479b739a3 100644 --- a/src/fortify/src/Fortify.php +++ b/src/fortify/src/Fortify.php @@ -51,6 +51,8 @@ class Fortify private static ?Closure $confirmPasswordsUsingCallback = null; + private static ?Closure $recoveryCodeGenerator = null; + private static bool $registersRoutes = self::DEFAULT_REGISTERS_ROUTES; private static ?EncrypterContract $encrypter = null; @@ -320,6 +322,24 @@ public static function resetUserPasswordsUsing(callable|string $callback): void self::container()->singleton(ResetsUserPasswords::class, self::bindingConcrete($callback)); } + /** + * Register a callback that should be used to generate recovery codes. + * + * Boot-only. The callback persists in static state for the worker lifetime and affects every subsequent recovery code generation. + */ + public static function generateRecoveryCodesUsing(callable $callback): void + { + self::$recoveryCodeGenerator = Closure::fromCallable($callback); + } + + /** + * Get the configured recovery code generator. + */ + public static function recoveryCodeGenerator(): ?Closure + { + return self::$recoveryCodeGenerator; + } + /** * Determine if Fortify is confirming two factor authentication configurations. */ @@ -426,6 +446,7 @@ public static function flushState(): void self::$authenticateThroughCallback = null; self::$authenticateUsingCallback = null; self::$confirmPasswordsUsingCallback = null; + self::$recoveryCodeGenerator = null; self::$registersRoutes = self::DEFAULT_REGISTERS_ROUTES; self::$encrypter = null; self::$redirectUsingCallbacks = []; diff --git a/src/fortify/src/RecoveryCode.php b/src/fortify/src/RecoveryCode.php index 75ff45da7a..32736272a6 100644 --- a/src/fortify/src/RecoveryCode.php +++ b/src/fortify/src/RecoveryCode.php @@ -13,6 +13,10 @@ class RecoveryCode */ public static function generate(): string { + if ($generator = Fortify::recoveryCodeGenerator()) { + return $generator(); + } + return Str::random(10) . '-' . Str::random(10); } } diff --git a/tests/Fortify/FortifyApiTest.php b/tests/Fortify/FortifyApiTest.php index b86245c175..81d73eae79 100644 --- a/tests/Fortify/FortifyApiTest.php +++ b/tests/Fortify/FortifyApiTest.php @@ -227,6 +227,7 @@ public static function privateStaticFortifyPropertiesProvider(): array 'authentication pipeline callback' => ['authenticateThroughCallback'], 'authentication callback' => ['authenticateUsingCallback'], 'password confirmation callback' => ['confirmPasswordsUsingCallback'], + 'recovery code generator' => ['recoveryCodeGenerator'], 'route registration flag' => ['registersRoutes'], 'encrypter' => ['encrypter'], 'redirect callbacks' => ['redirectUsingCallbacks'], diff --git a/tests/Fortify/FortifyServiceProviderTest.php b/tests/Fortify/FortifyServiceProviderTest.php index ad86ef0252..651eaaf970 100644 --- a/tests/Fortify/FortifyServiceProviderTest.php +++ b/tests/Fortify/FortifyServiceProviderTest.php @@ -9,12 +9,9 @@ use Hypervel\Fortify\Contracts\CreatesNewUsers; use Hypervel\Fortify\Contracts\RedirectsIfTwoFactorAuthenticatable; use Hypervel\Fortify\Contracts\TwoFactorAuthenticationProvider as TwoFactorAuthenticationProviderContract; -use Hypervel\Fortify\Contracts\TwoFactorDisabledResponse as TwoFactorDisabledResponseContract; -use Hypervel\Fortify\Contracts\TwoFactorEnabledResponse as TwoFactorEnabledResponseContract; use Hypervel\Fortify\Fortify; use Hypervel\Fortify\FortifyServiceProvider; -use Hypervel\Fortify\Http\Responses\TwoFactorDisabledResponse; -use Hypervel\Fortify\Http\Responses\TwoFactorEnabledResponse; +use Hypervel\Fortify\RecoveryCode; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Hypervel\Passkeys\Passkeys; @@ -62,19 +59,6 @@ public function toResponse(Request $request): Response $response->assertExactJson(['foo' => 'bar']); } - public function testTwoFactorResponseBindingsUseMatchingContracts(): void - { - $this->assertInstanceOf( - TwoFactorEnabledResponse::class, - $this->app->make(TwoFactorEnabledResponseContract::class) - ); - - $this->assertInstanceOf( - TwoFactorDisabledResponse::class, - $this->app->make(TwoFactorDisabledResponseContract::class) - ); - } - public function testTwoFactorAuthenticationProviderUsesFrameworkClock(): void { $timestamp = 946684800; @@ -204,6 +188,13 @@ public function testCustomRedirectIfTwoFactorAuthenticatableIsResolvedFreshAfter $this->assertNotSame($instanceA, $instanceB); } + public function testRecoveryCodeGenerationCanBeCustomized(): void + { + Fortify::generateRecoveryCodesUsing(fn (): string => 'recovery-code'); + + $this->assertSame('recovery-code', RecoveryCode::generate()); + } + public function testActionsCanBeRegisteredWithNonClosureCallables(): void { $creator = m::mock(CreatesNewUsers::class); From 17f8dbadf886e99387aeda2a5d1a6054cd23fee8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:02:17 +0000 Subject: [PATCH 05/33] Keep pending two-factor confirmation on a repeated enable request laravel/fortify PR 696 fixed a repeated "enable" request deleting a secret that still awaited confirmation. EnableTwoFactorAuthentication leaves an existing secret in place, but the settings page's state check then saw a stale two_factor_confirming_at value and treated the pending setup as abandoned, disabling two-factor authentication. TwoFactorAuthenticationController::store() now clears that session value while a secret awaits confirmation, so the next settings page load starts a fresh confirmation window. The upstream test is ported. Upstream reference: laravel/fortify 1.x at c456642584. Validation: the Fortify suite, formatting and PHPStan pass. --- .../TwoFactorAuthenticationController.php | 7 ++++ .../InteractsWithTwoFactorStateTest.php | 42 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/fortify/src/Http/Controllers/TwoFactorAuthenticationController.php b/src/fortify/src/Http/Controllers/TwoFactorAuthenticationController.php index 832b9b3315..1584290693 100644 --- a/src/fortify/src/Http/Controllers/TwoFactorAuthenticationController.php +++ b/src/fortify/src/Http/Controllers/TwoFactorAuthenticationController.php @@ -10,6 +10,7 @@ use Hypervel\Fortify\Actions\EnableTwoFactorAuthentication; use Hypervel\Fortify\Contracts\TwoFactorDisabledResponse; use Hypervel\Fortify\Contracts\TwoFactorEnabledResponse; +use Hypervel\Fortify\Fortify; use Hypervel\Http\Request; use Hypervel\Routing\Controller; @@ -25,6 +26,12 @@ public function store(Request $request, EnableTwoFactorAuthentication $enable): $enable($user, $request->boolean('force', false)); + if (Fortify::confirmsTwoFactorAuthentication() + && ! is_null($user->getAttribute('two_factor_secret')) + && is_null($user->getAttribute('two_factor_confirmed_at'))) { + $request->session()->remove('two_factor_confirming_at'); + } + return app(TwoFactorEnabledResponse::class); } diff --git a/tests/Fortify/InteractsWithTwoFactorStateTest.php b/tests/Fortify/InteractsWithTwoFactorStateTest.php index 53fd5d3067..e00e8a2c73 100644 --- a/tests/Fortify/InteractsWithTwoFactorStateTest.php +++ b/tests/Fortify/InteractsWithTwoFactorStateTest.php @@ -6,8 +6,11 @@ use Hypervel\Database\Eloquent\MissingAttributeException; use Hypervel\Database\Eloquent\Model; +use Hypervel\Fortify\Actions\EnableTwoFactorAuthentication; use Hypervel\Fortify\Features; +use Hypervel\Fortify\Http\Controllers\TwoFactorAuthenticationController; use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Http\Request; use Hypervel\Testbench\Attributes\WithMigration; use Hypervel\Tests\Fortify\Fixtures\FormRequestInteractsWithTwoFactorState; use Hypervel\Tests\Fortify\Fixtures\UserWithTwoFactor; @@ -234,6 +237,45 @@ public function testConfirmingAtTimestampIsCurrentTime(): void $this->assertLessThanOrEqual($afterTime, $timestamp); } + public function testRepeatEnableRequestDoesNotDisablePendingConfirmation(): void + { + $user = $this->createUser([ + 'two_factor_secret' => encrypt('secret'), + 'two_factor_confirmed_at' => null, + ]); + $formRequest = $this->createFormRequestWithUser($user); + $formRequest->session()->put('two_factor_empty_at', time() - 10); + + // The settings page is loaded right after the secret was generated, so + // confirming_at gets stamped for the first time here. + $formRequest->ensureStateIsValid(); + $this->assertTrue($formRequest->session()->has('two_factor_confirming_at')); + + $secret = $user->two_factor_secret; + + // A moment later the user clicks "Enable 2FA" again without ever confirming. + // The secret already exists, so EnableTwoFactorAuthentication no-ops, but the + // controller should still clear the now-stale confirming_at value. + $enableRequest = Request::create('/user/two-factor-authentication', 'POST'); + $enableRequest->setUserResolver(fn (): UserWithTwoFactor => $user); + $enableRequest->setHypervelSession($formRequest->session()); + + app(TwoFactorAuthenticationController::class)->store( + $enableRequest, + app(EnableTwoFactorAuthentication::class) + ); + + $this->assertFalse($formRequest->session()->has('two_factor_confirming_at')); + + // The settings page loads again, sees a fresh confirmation attempt starting, + // and should not treat it as abandoned. + $formRequest = $this->createFormRequestWithUser($user); + $formRequest->ensureStateIsValid(); + + $this->assertEquals($secret, $user->fresh()->two_factor_secret); + $this->assertTrue($formRequest->session()->has('two_factor_confirming_at')); + } + private function createUser(array $attributes = []): UserWithTwoFactor { $defaults = [ From 2f05c842d906a77c1269d99852fde9b4d5be80b8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:02:20 +0000 Subject: [PATCH 06/33] Timebox credential checks before the two-factor challenge laravel/fortify PR 697 wraps the credential check in RedirectIfTwoFactorAuthenticatable in a Timebox, so response times no longer reveal whether a username exists. Hypervel uses the auth.timebox_duration setting that SessionGuard already uses instead of upstream's fixed 200ms, and returns early once the credentials are valid, as SessionGuard does, so only failed attempts wait for the full duration. A test fakes Sleep to check that a successful login never sleeps while a wrong password and an unknown user both wait. Upstream reference: laravel/fortify 1.x at c456642584. Validation: the Fortify suite, formatting and PHPStan pass. --- .../RedirectIfTwoFactorAuthenticatable.php | 27 +++++++++------ ...atedSessionControllerWithTwoFactorTest.php | 34 +++++++++++++++++++ 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/fortify/src/Actions/RedirectIfTwoFactorAuthenticatable.php b/src/fortify/src/Actions/RedirectIfTwoFactorAuthenticatable.php index d6c7a5b707..302dd2baa5 100644 --- a/src/fortify/src/Actions/RedirectIfTwoFactorAuthenticatable.php +++ b/src/fortify/src/Actions/RedirectIfTwoFactorAuthenticatable.php @@ -18,6 +18,7 @@ use Hypervel\Fortify\LoginRateLimiter; use Hypervel\Fortify\TwoFactorAuthenticatable; use Hypervel\Http\Request; +use Hypervel\Support\Timebox; use Hypervel\Validation\ValidationException; use RuntimeException; use Symfony\Component\HttpFoundation\Response; @@ -75,21 +76,27 @@ protected function validateCredentials(Request $request): Authenticatable&Model } $provider = $this->provider(); - $user = $provider->retrieveByCredentials($request->only(Fortify::username(), 'password')); - $password = $request->input('password'); + return (new Timebox)->call(function (Timebox $timebox) use ($request, $provider): Authenticatable&Model { + $user = $provider->retrieveByCredentials($request->only(Fortify::username(), 'password')); - if (! $user instanceof Authenticatable || ! $user instanceof Model || ! $provider->validateCredentials($user, ['password' => $password])) { - $this->fireFailedEvent($request, $user); + $password = $request->input('password'); - $this->throwFailedAuthenticationException($request); - } + if (! $user instanceof Authenticatable || ! $user instanceof Model || ! $provider->validateCredentials($user, ['password' => $password])) { + $this->fireFailedEvent($request, $user); - if ($this->config->boolean('hashing.rehash_on_login')) { - $provider->rehashPasswordIfRequired($user, ['password' => $password]); - } + $this->throwFailedAuthenticationException($request); + } - return $user; + if ($this->config->boolean('hashing.rehash_on_login')) { + $provider->rehashPasswordIfRequired($user, ['password' => $password]); + } + + // Only failed attempts need a fixed duration, so successful logins skip the wait, as SessionGuard does. + $timebox->returnEarly(); + + return $user; + }, $this->config->integer('auth.timebox_duration')); } /** diff --git a/tests/Fortify/AuthenticatedSessionControllerWithTwoFactorTest.php b/tests/Fortify/AuthenticatedSessionControllerWithTwoFactorTest.php index 04baefd59c..e7754d7390 100644 --- a/tests/Fortify/AuthenticatedSessionControllerWithTwoFactorTest.php +++ b/tests/Fortify/AuthenticatedSessionControllerWithTwoFactorTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Fortify; +use Carbon\CarbonInterval; use Carbon\FactoryImmutable; use Hypervel\Database\Eloquent\MissingAttributeException; use Hypervel\Database\Eloquent\Model; @@ -17,6 +18,7 @@ use Hypervel\Support\Facades\Auth; use Hypervel\Support\Facades\Event; use Hypervel\Support\Facades\Hash; +use Hypervel\Support\Sleep; use Hypervel\Testbench\Attributes\DefineEnvironment; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\Attributes\WithMigration; @@ -177,6 +179,38 @@ public function testDoesNotRehashUserPasswordWhenRedirectingToTwoFactorChallenge $this->assertSame($user->password, $user->fresh()->password); } + #[WithConfig('auth.timebox_duration', 1000000)] + public function testFailedCredentialValidationIsTimeboxed(): void + { + Sleep::fake(); + + UserWithTwoFactor::forceCreate([ + 'name' => 'Taylor Otwell', + 'email' => 'taylor@hypervel.org', + 'password' => bcrypt('secret'), + 'two_factor_secret' => 'test-secret', + ]); + + $this->post('/login', [ + 'email' => 'taylor@hypervel.org', + 'password' => 'secret', + ])->assertRedirect('/two-factor-challenge'); + + Sleep::assertNeverSlept(); + + $this->post('/login', [ + 'email' => 'taylor@hypervel.org', + 'password' => 'wrong-password', + ])->assertSessionHasErrors('email'); + + $this->post('/login', [ + 'email' => 'missing@hypervel.org', + 'password' => 'secret', + ])->assertSessionHasErrors('email'); + + Sleep::assertSlept(static fn (CarbonInterval $duration): bool => $duration->totalMicroseconds > 500000, 2); + } + public function testTwoFactorChallengeCanBePassedViaCode(): void { Event::fake(); From d62687b62bc0323574a7ec0e86a1d80b1e45eeed Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:02:24 +0000 Subject: [PATCH 07/33] Test Fortify controllers against real users and services laravel/fortify PR 699 moved its tests from Mockery to the Double library and rewrote many of them to use real users, password reset tokens, notifications, views, rate limiting and guards instead of mocks. Hypervel keeps Mockery, but ports those rewrites across ten controller test files, so the tests now check persisted state, sent notifications and authentication results rather than mock calls. The reset link test selects the Workbench user model so notifications can be sent, and the Fortify test base drops its unused user model fallback and reads the model with a typed config getter. Two existing tests are also made meaningful. The login tests' false remember case was filtered out before the request, so it sent the same payload as the null case; only null is filtered now. The shared email verification helper only asserted the redirect, and now also checks that the user's email is verified. Upstream reference: laravel/fortify 1.x at c456642584. Validation: each changed test file, the Fortify suite and formatting pass. --- .../AuthenticatedSessionControllerTest.php | 53 ++++---- .../ConfirmablePasswordControllerTest.php | 5 +- ...VerificationNotificationControllerTest.php | 34 +++--- .../EmailVerificationPromptControllerTest.php | 32 ++--- tests/Fortify/NewPasswordControllerTest.php | 114 +++++++----------- tests/Fortify/PasswordControllerTest.php | 9 +- ...PasswordResetLinkRequestControllerTest.php | 43 ++++--- .../ProfileInformationControllerTest.php | 13 +- .../Fortify/RegisteredUserControllerTest.php | 56 +++------ tests/Fortify/TestCase.php | 6 +- tests/Fortify/VerifyEmailControllerTest.php | 51 ++++---- 11 files changed, 187 insertions(+), 229 deletions(-) diff --git a/tests/Fortify/AuthenticatedSessionControllerTest.php b/tests/Fortify/AuthenticatedSessionControllerTest.php index 694ee63a8e..cc996e68fa 100644 --- a/tests/Fortify/AuthenticatedSessionControllerTest.php +++ b/tests/Fortify/AuthenticatedSessionControllerTest.php @@ -5,8 +5,7 @@ namespace Hypervel\Tests\Fortify; use Hypervel\Auth\Events\Logout; -use Hypervel\Contracts\Auth\Authenticatable; -use Hypervel\Fortify\Contracts\LoginViewResponse; +use Hypervel\Fortify\Fortify; use Hypervel\Fortify\LoginRateLimiter; use Hypervel\Foundation\Http\FormRequest; use Hypervel\Foundation\Testing\RefreshDatabase; @@ -14,12 +13,13 @@ use Hypervel\RateLimiter\RateLimiter; use Hypervel\Support\Facades\Auth; use Hypervel\Support\Facades\Event; +use Hypervel\Support\Sleep; use Hypervel\Testbench\Attributes\WithMigration; -use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; use ReflectionClass; use Workbench\App\Models\User; +use Workbench\Database\Factories\UserFactory; #[WithMigration] class AuthenticatedSessionControllerTest extends TestCase @@ -28,9 +28,7 @@ class AuthenticatedSessionControllerTest extends TestCase public function testTheLoginViewIsReturned(): void { - $this->mock(LoginViewResponse::class) - ->shouldReceive('toResponse') - ->andReturn(response('hello world')); + Fortify::loginView(fn (): string => 'hello world'); $response = $this->get('/login'); @@ -68,7 +66,7 @@ protected function assertUserCanBeAuthenticated(?bool $remember = null): void 'email' => 'taylor@laravel.com', 'password' => 'secret', 'remember' => $remember, - ])); + ], static fn (string|bool|null $value): bool => $value !== null)); $response->assertRedirect('/home'); } @@ -92,13 +90,18 @@ public function testValidationExceptionReturnedOnFailure(): void public function testLoginAttemptsAreThrottled(): void { - $this->mock(LoginRateLimiter::class, function ($mock) { - $mock->shouldReceive('tooManyAttempts')->andReturn(true); - $mock->shouldReceive('availableIn')->andReturn(10); - }); + // Skip the authentication timebox wait on each failed attempt. + Sleep::fake(); + + foreach (range(1, 5) as $attempt) { + $this->postJson('/login', [ + 'email' => 'taylor@hypervel.org', + 'password' => 'secret', + ])->assertStatus(422); + } $response = $this->postJson('/login', [ - 'email' => 'taylor@laravel.com', + 'email' => 'taylor@hypervel.org', 'password' => 'secret', ]); @@ -110,19 +113,13 @@ public function testLoginAttemptsAreThrottled(): void public function testCantBypassThrottleWithSpecialCharacters(string $username, string $expectedResult): void { $loginRateLimiter = new LoginRateLimiter( - $this->mock(RateLimiter::class) + $this->app->make(RateLimiter::class) ); $reflection = new ReflectionClass($loginRateLimiter); $method = $reflection->getMethod('throttleKey'); - $request = $this->mock( - Request::class, - static function ($mock) use ($username) { - $mock->shouldReceive('input')->andReturn($username); - $mock->shouldReceive('ip')->andReturn('192.168.0.1'); - } - ); + $request = Request::create('/login', 'POST', ['email' => $username], server: ['REMOTE_ADDR' => '192.168.0.1']); self::assertSame('web|' . $expectedResult . '|192.168.0.1', $method->invoke($loginRateLimiter, $request)); } @@ -163,26 +160,22 @@ public function testLockoutIsScopedToGuard(): void public function testTheUserCanLogoutOfTheApplication(): void { - Auth::guard()->setUser( - m::mock(Authenticatable::class)->shouldIgnoreMissing() - ); + $user = User::forceCreate(UserFactory::new()->raw()); - $response = $this->post('/logout'); + $response = $this->actingAs($user)->post('/logout'); $response->assertRedirect('/'); - $this->assertNull(Auth::guard()->getUser()); + $this->assertGuest(); } public function testTheUserCanLogoutOfTheApplicationUsingJsonRequest(): void { - Auth::guard()->setUser( - m::mock(Authenticatable::class)->shouldIgnoreMissing() - ); + $user = User::forceCreate(UserFactory::new()->raw()); - $response = $this->postJson('/logout'); + $response = $this->actingAs($user)->postJson('/logout'); $response->assertStatus(204); - $this->assertNull(Auth::guard()->getUser()); + $this->assertGuest(); } public function testCaseInsensitiveUsernamesCanBeUsed(): void diff --git a/tests/Fortify/ConfirmablePasswordControllerTest.php b/tests/Fortify/ConfirmablePasswordControllerTest.php index e441931dd8..74592450d7 100644 --- a/tests/Fortify/ConfirmablePasswordControllerTest.php +++ b/tests/Fortify/ConfirmablePasswordControllerTest.php @@ -5,7 +5,6 @@ namespace Hypervel\Tests\Fortify; use Hypervel\Contracts\Foundation\Application as ApplicationContract; -use Hypervel\Fortify\Contracts\ConfirmPasswordViewResponse; use Hypervel\Fortify\Fortify; use Hypervel\Foundation\Auth\User; use Hypervel\Foundation\Testing\RefreshDatabase; @@ -35,9 +34,7 @@ protected function afterRefreshingDatabase(): void public function testTheConfirmPasswordViewIsReturned(): void { - $this->mock(ConfirmPasswordViewResponse::class) - ->shouldReceive('toResponse') - ->andReturn(response('hello world')); + Fortify::confirmPasswordView(fn (): string => 'hello world'); $response = $this->withoutExceptionHandling()->actingAs($this->user)->get( '/user/confirm-password' diff --git a/tests/Fortify/EmailVerificationNotificationControllerTest.php b/tests/Fortify/EmailVerificationNotificationControllerTest.php index b59d900f0f..4eefe0f765 100644 --- a/tests/Fortify/EmailVerificationNotificationControllerTest.php +++ b/tests/Fortify/EmailVerificationNotificationControllerTest.php @@ -4,48 +4,51 @@ namespace Hypervel\Tests\Fortify; -use Hypervel\Contracts\Auth\Authenticatable; -use Mockery as m; - +use Hypervel\Auth\Notifications\VerifyEmail; +use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Support\Facades\Notification; +use Hypervel\Testbench\Attributes\WithMigration; +use Workbench\App\Models\User; +use Workbench\Database\Factories\UserFactory; + +#[WithMigration] class EmailVerificationNotificationControllerTest extends TestCase { + use RefreshDatabase; + public function testEmailVerificationNotificationCanBeSent(): void { - $user = m::mock(Authenticatable::class); + Notification::fake(); - $user->shouldReceive('hasVerifiedEmail')->andReturn(false); - $user->shouldReceive('getAuthIdentifier')->andReturn(1); - $user->shouldReceive('sendEmailVerificationNotification')->once(); + $user = User::forceCreate(UserFactory::new()->unverified()->raw()); $response = $this->from('/email/verify') ->actingAs($user) ->post('/email/verification-notification'); $response->assertRedirect('/email/verify'); + Notification::assertSentTo($user, VerifyEmail::class); } public function testUserIsRedirectIfAlreadyVerified(): void { - $user = m::mock(Authenticatable::class); + Notification::fake(); - $user->shouldReceive('hasVerifiedEmail')->andReturn(true); - $user->shouldReceive('getAuthIdentifier')->andReturn(1); - $user->shouldReceive('sendEmailVerificationNotification')->never(); + $user = User::forceCreate(UserFactory::new()->raw()); $response = $this->from('/email/verify') ->actingAs($user) ->post('/email/verification-notification'); $response->assertRedirect('/home'); + Notification::assertNothingSent(); } public function testUserIsRedirectToIntendedUrlIfAlreadyVerified(): void { - $user = m::mock(Authenticatable::class); + Notification::fake(); - $user->shouldReceive('hasVerifiedEmail')->andReturn(true); - $user->shouldReceive('getAuthIdentifier')->andReturn(1); - $user->shouldReceive('sendEmailVerificationNotification')->never(); + $user = User::forceCreate(UserFactory::new()->raw()); $response = $this->from('/email/verify') ->actingAs($user) @@ -53,5 +56,6 @@ public function testUserIsRedirectToIntendedUrlIfAlreadyVerified(): void ->post('/email/verification-notification'); $response->assertRedirect('http://foo.com/bar'); + Notification::assertNothingSent(); } } diff --git a/tests/Fortify/EmailVerificationPromptControllerTest.php b/tests/Fortify/EmailVerificationPromptControllerTest.php index 6cd29726b3..37ccae64c7 100644 --- a/tests/Fortify/EmailVerificationPromptControllerTest.php +++ b/tests/Fortify/EmailVerificationPromptControllerTest.php @@ -4,20 +4,22 @@ namespace Hypervel\Tests\Fortify; -use Hypervel\Contracts\Auth\Authenticatable; -use Hypervel\Fortify\Contracts\VerifyEmailViewResponse; -use Mockery as m; +use Hypervel\Fortify\Fortify; +use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Testbench\Attributes\WithMigration; +use Workbench\App\Models\User; +use Workbench\Database\Factories\UserFactory; +#[WithMigration] class EmailVerificationPromptControllerTest extends TestCase { + use RefreshDatabase; + public function testTheEmailVerificationPromptViewIsReturned(): void { - $this->mock(VerifyEmailViewResponse::class) - ->shouldReceive('toResponse') - ->andReturn(response('hello world')); + Fortify::verifyEmailView(fn (): string => 'hello world'); - $user = m::mock(Authenticatable::class); - $user->shouldReceive('hasVerifiedEmail')->andReturn(false); + $user = User::forceCreate(UserFactory::new()->unverified()->raw()); $response = $this->actingAs($user)->get('/email/verify'); @@ -27,12 +29,7 @@ public function testTheEmailVerificationPromptViewIsReturned(): void public function testUserIsRedirectHomeIfAlreadyVerified(): void { - $this->mock(VerifyEmailViewResponse::class) - ->shouldReceive('toResponse') - ->andReturn(response('hello world')); - - $user = m::mock(Authenticatable::class); - $user->shouldReceive('hasVerifiedEmail')->andReturn(true); + $user = User::forceCreate(UserFactory::new()->raw()); $response = $this->actingAs($user)->get('/email/verify'); @@ -41,12 +38,7 @@ public function testUserIsRedirectHomeIfAlreadyVerified(): void public function testUserIsRedirectToIntendedUrlIfAlreadyVerified(): void { - $this->mock(VerifyEmailViewResponse::class) - ->shouldReceive('toResponse') - ->andReturn(response('hello world')); - - $user = m::mock(Authenticatable::class); - $user->shouldReceive('hasVerifiedEmail')->andReturn(true); + $user = User::forceCreate(UserFactory::new()->raw()); $response = $this->actingAs($user) ->withSession(['url.intended' => 'http://foo.com/bar']) diff --git a/tests/Fortify/NewPasswordControllerTest.php b/tests/Fortify/NewPasswordControllerTest.php index 31f97a58d8..aa9673453e 100644 --- a/tests/Fortify/NewPasswordControllerTest.php +++ b/tests/Fortify/NewPasswordControllerTest.php @@ -4,22 +4,27 @@ namespace Hypervel\Tests\Fortify; +use Hypervel\Auth\Events\PasswordReset; use Hypervel\Contracts\Auth\PasswordBroker; -use Hypervel\Fortify\Contracts\ResetPasswordViewResponse; +use Hypervel\Database\Eloquent\Model; use Hypervel\Fortify\Contracts\ResetsUserPasswords; use Hypervel\Fortify\Fortify; -use Hypervel\Support\Facades\Auth; +use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Support\Facades\Event; use Hypervel\Support\Facades\Password; +use Hypervel\Testbench\Attributes\WithMigration; use Mockery as m; use Workbench\App\Models\User; +use Workbench\Database\Factories\UserFactory; +#[WithMigration] class NewPasswordControllerTest extends TestCase { + use RefreshDatabase; + public function testTheNewPasswordViewIsReturned(): void { - $this->mock(ResetPasswordViewResponse::class) - ->shouldReceive('toResponse') - ->andReturn(response('hello world')); + Fortify::resetPasswordView(fn (): string => 'hello world'); $response = $this->get('/reset-password/token'); @@ -29,45 +34,37 @@ public function testTheNewPasswordViewIsReturned(): void public function testPasswordCanBeReset(): void { - Password::shouldReceive('broker')->andReturn($broker = m::mock(PasswordBroker::class)); - - $user = m::mock(TestNewPasswordUser::class)->makePartial(); + Event::fake([PasswordReset::class]); - $user->shouldReceive('setRememberToken')->once(); - $user->shouldReceive('save')->once(); + $user = User::forceCreate(UserFactory::new()->raw(['email' => 'taylor@hypervel.org'])); + $token = Password::broker()->createToken($user); - $updater = $this->mock(ResetsUserPasswords::class); - $updater->shouldReceive('reset')->once()->with($user, m::type('array')); - - $broker->shouldReceive('reset')->andReturnUsing(function ($input, $callback) use ($user) { - $callback($user, 'password'); - - return Password::PASSWORD_RESET; - }); + $this->mock(ResetsUserPasswords::class) + ->shouldReceive('reset') + ->once() + ->with(m::on(fn (Model $resetUser): bool => $resetUser->is($user)), m::type('array')); $response = $this->withoutExceptionHandling()->post('/reset-password', [ - 'token' => 'token', - 'email' => 'taylor@laravel.com', - 'password' => 'password', - 'password_confirmation' => 'password', + 'token' => $token, + 'email' => 'taylor@hypervel.org', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', ]); $response->assertStatus(302); $response->assertRedirect(Fortify::redirects('password-reset', route('login'))); - $this->assertNull(Auth::getUser()); + $this->assertGuest(); + $this->assertNotSame($user->remember_token, $user->fresh()->remember_token); + Event::assertDispatched(PasswordReset::class); } public function testPasswordResetCanFail(): void { - Password::shouldReceive('broker')->andReturn($broker = m::mock(PasswordBroker::class)); - - $broker->shouldReceive('reset')->andReturnUsing(function ($input, $callback) { - return Password::INVALID_TOKEN; - }); + User::forceCreate(UserFactory::new()->raw(['email' => 'taylor@hypervel.org'])); $response = $this->withoutExceptionHandling()->post('/reset-password', [ 'token' => 'token', - 'email' => 'taylor@laravel.com', + 'email' => 'taylor@hypervel.org', 'password' => 'password', 'password_confirmation' => 'password', ]); @@ -78,15 +75,11 @@ public function testPasswordResetCanFail(): void public function testPasswordResetCanFailWithJson(): void { - Password::shouldReceive('broker')->andReturn($broker = m::mock(PasswordBroker::class)); - - $broker->shouldReceive('reset')->andReturnUsing(function ($input, $callback) { - return Password::INVALID_TOKEN; - }); + User::forceCreate(UserFactory::new()->raw(['email' => 'taylor@hypervel.org'])); $response = $this->postJson('/reset-password', [ 'token' => 'token', - 'email' => 'taylor@laravel.com', + 'email' => 'taylor@hypervel.org', 'password' => 'password', 'password_confirmation' => 'password', ]); @@ -100,15 +93,15 @@ public function testPasswordCanBeResetWithCustomizedEmailAddressField(): void $this->app->make('config')->set('fortify.email', 'emailAddress'); Password::shouldReceive('broker')->andReturn($broker = m::mock(PasswordBroker::class)); - $user = m::mock(TestNewPasswordUser::class)->makePartial(); + $user = User::forceCreate(UserFactory::new()->raw()); + $rememberToken = $user->remember_token; - $user->shouldReceive('setRememberToken')->once(); - $user->shouldReceive('save')->once(); - - $updater = $this->mock(ResetsUserPasswords::class); - $updater->shouldReceive('reset')->once()->with($user, m::type('array')); + $this->mock(ResetsUserPasswords::class) + ->shouldReceive('reset') + ->once() + ->with($user, m::type('array')); - $broker->shouldReceive('reset')->andReturnUsing(function ($input, $callback) use ($user) { + $broker->shouldReceive('reset')->once()->andReturnUsing(function (array $input, callable $callback) use ($user): string { $callback($user, 'password'); return Password::PASSWORD_RESET; @@ -123,7 +116,8 @@ public function testPasswordCanBeResetWithCustomizedEmailAddressField(): void $response->assertStatus(302); $response->assertRedirect(Fortify::redirects('password-reset', route('login'))); - $this->assertNull(Auth::getUser()); + $this->assertGuest(); + $this->assertNotSame($rememberToken, $user->remember_token); } public function testPasswordIsRequired(): void @@ -140,41 +134,23 @@ public function testPasswordIsRequired(): void public function testCaseInsensitiveUsernamesCanBeUsed(): void { $this->app->make('config')->set('fortify.lowercase_usernames', true); - Password::shouldReceive('broker')->andReturn($broker = m::mock(PasswordBroker::class)); - - $user = m::mock(TestNewPasswordUser::class)->makePartial(); - $user->shouldReceive('setRememberToken')->once(); - $user->shouldReceive('save')->once(); + $user = User::forceCreate(UserFactory::new()->raw(['email' => 'john.doe@example.com'])); + $token = Password::broker()->createToken($user); - $updater = $this->mock(ResetsUserPasswords::class); - $updater->shouldReceive('reset')->once()->with($user, m::type('array')); - - $broker->shouldReceive('reset') + $this->mock(ResetsUserPasswords::class) + ->shouldReceive('reset') ->once() - ->with( - m::on(fn ($credentials) => $credentials['email'] === 'john.doe@example.com'), - m::type('callable') - ) - ->andReturnUsing(function ($input, $callback) use ($user) { - $callback($user, 'password'); - - return Password::PASSWORD_RESET; - }); + ->with(m::on(fn (Model $resetUser): bool => $resetUser->is($user)), m::type('array')); $response = $this->withoutExceptionHandling()->post('/reset-password', [ - 'token' => 'token', + 'token' => $token, 'email' => 'John.Doe@example.com', - 'password' => 'password', - 'password_confirmation' => 'password', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', ]); $response->assertStatus(302); $response->assertRedirect(Fortify::redirects('password-reset', route('login'))); - $this->assertNull(Auth::getUser()); } } - -class TestNewPasswordUser extends User -{ -} diff --git a/tests/Fortify/PasswordControllerTest.php b/tests/Fortify/PasswordControllerTest.php index 2f4e61e8a9..048a8986fa 100644 --- a/tests/Fortify/PasswordControllerTest.php +++ b/tests/Fortify/PasswordControllerTest.php @@ -4,14 +4,12 @@ namespace Hypervel\Tests\Fortify; -use Hypervel\Contracts\Auth\PasswordBroker; use Hypervel\Fortify\Contracts\UpdatesUserPasswords; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Support\Facades\Password; use Hypervel\Testbench\Attributes\WithMigration; use Hypervel\Tests\Fortify\Fixtures\UpdateUserPassword; use Hypervel\Validation\ValidationException; -use Mockery as m; use Workbench\App\Models\User; use Workbench\Database\Factories\UserFactory; @@ -24,11 +22,7 @@ public function testPasswordsCanBeUpdated(): void { $user = $this->createUser(); - Password::shouldReceive('broker')->andReturn($broker = m::mock(PasswordBroker::class)); - - $broker->shouldReceive('deleteToken') - ->once() - ->with($user); + Password::broker()->createToken($user); $this->mock(UpdatesUserPasswords::class) ->shouldReceive('update') @@ -46,6 +40,7 @@ public function testPasswordsCanBeUpdated(): void ]); $response->assertStatus(200); + $this->assertDatabaseMissing('password_reset_tokens', ['email' => $user->email]); } public function testPasswordsCannotBeUpdatedWithoutCurrentPassword(): void diff --git a/tests/Fortify/PasswordResetLinkRequestControllerTest.php b/tests/Fortify/PasswordResetLinkRequestControllerTest.php index 6ceadbd3b4..167173906f 100644 --- a/tests/Fortify/PasswordResetLinkRequestControllerTest.php +++ b/tests/Fortify/PasswordResetLinkRequestControllerTest.php @@ -4,18 +4,27 @@ namespace Hypervel\Tests\Fortify; +use Hypervel\Auth\Notifications\ResetPassword; use Hypervel\Contracts\Auth\PasswordBroker; -use Hypervel\Fortify\Contracts\RequestPasswordResetLinkViewResponse; +use Hypervel\Fortify\Fortify; +use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Support\Facades\Notification; use Hypervel\Support\Facades\Password; +use Hypervel\Testbench\Attributes\WithConfig; +use Hypervel\Testbench\Attributes\WithMigration; use Mockery as m; +use Workbench\App\Models\User; +use Workbench\Database\Factories\UserFactory; +#[WithMigration] +#[WithConfig('auth.providers.users.model', User::class)] class PasswordResetLinkRequestControllerTest extends TestCase { + use RefreshDatabase; + public function testTheResetLinkRequestViewIsReturned(): void { - $this->mock(RequestPasswordResetLinkViewResponse::class) - ->shouldReceive('toResponse') - ->andReturn(response('hello world')); + Fortify::requestPasswordResetLinkView(fn (): string => 'hello world'); $response = $this->get('/forgot-password'); @@ -25,24 +34,23 @@ public function testTheResetLinkRequestViewIsReturned(): void public function testResetLinkCanBeSuccessfullyRequested(): void { - Password::shouldReceive('broker')->andReturn($broker = m::mock(PasswordBroker::class)); + Notification::fake(); - $broker->shouldReceive('sendResetLink')->andReturn(Password::RESET_LINK_SENT); + $user = User::forceCreate(UserFactory::new()->raw(['email' => 'taylor@hypervel.org'])); $response = $this->from(url('/forgot-password')) - ->post('/forgot-password', ['email' => 'taylor@laravel.com']); + ->post('/forgot-password', ['email' => 'taylor@hypervel.org']); $response->assertStatus(302); $response->assertRedirect('/forgot-password'); $response->assertSessionHasNoErrors(); $response->assertSessionHas('status', trans(Password::RESET_LINK_SENT)); + Notification::assertSentTo($user, ResetPassword::class); } public function testResetLinkRequestCanFail(): void { - Password::shouldReceive('broker')->andReturn($broker = m::mock(PasswordBroker::class)); - - $broker->shouldReceive('sendResetLink')->andReturn(Password::INVALID_USER); + Notification::fake(); $response = $this->from(url('/forgot-password')) ->post('/forgot-password', ['email' => 'taylor@laravel.com']); @@ -50,19 +58,19 @@ public function testResetLinkRequestCanFail(): void $response->assertStatus(302); $response->assertRedirect('/forgot-password'); $response->assertSessionHasErrors('email'); + Notification::assertNothingSent(); } public function testResetLinkRequestCanFailWithJson(): void { - Password::shouldReceive('broker')->andReturn($broker = m::mock(PasswordBroker::class)); - - $broker->shouldReceive('sendResetLink')->andReturn(Password::INVALID_USER); + Notification::fake(); $response = $this->from(url('/forgot-password')) ->postJson('/forgot-password', ['email' => 'taylor@laravel.com']); $response->assertStatus(422); $response->assertJsonValidationErrors('email'); + Notification::assertNothingSent(); } public function testResetLinkCanBeSuccessfullyRequestedWithCustomizedEmailField(): void @@ -70,7 +78,7 @@ public function testResetLinkCanBeSuccessfullyRequestedWithCustomizedEmailField( $this->app->make('config')->set('fortify.email', 'emailAddress'); Password::shouldReceive('broker')->andReturn($broker = m::mock(PasswordBroker::class)); - $broker->shouldReceive('sendResetLink')->andReturn(Password::RESET_LINK_SENT); + $broker->shouldReceive('sendResetLink')->once()->andReturn(Password::RESET_LINK_SENT); $response = $this->from(url('/forgot-password')) ->post('/forgot-password', ['emailAddress' => 'taylor@laravel.com']); @@ -84,16 +92,17 @@ public function testResetLinkCanBeSuccessfullyRequestedWithCustomizedEmailField( public function testCaseInsensitiveUsernamesCanBeUsed(): void { $this->app->make('config')->set('fortify.lowercase_usernames', true); - Password::shouldReceive('broker')->andReturn($broker = m::mock(PasswordBroker::class)); + Notification::fake(); - $broker->shouldReceive('sendResetLink')->andReturn(Password::RESET_LINK_SENT); + $user = User::forceCreate(UserFactory::new()->raw(['email' => 'taylor@hypervel.org'])); $response = $this->from(url('/forgot-password')) - ->post('/forgot-password', ['email' => 'TAYLOR@laravel.com']); + ->post('/forgot-password', ['email' => 'TAYLOR@hypervel.org']); $response->assertStatus(302); $response->assertRedirect('/forgot-password'); $response->assertSessionHasNoErrors(); $response->assertSessionHas('status', trans(Password::RESET_LINK_SENT)); + Notification::assertSentTo($user, ResetPassword::class); } } diff --git a/tests/Fortify/ProfileInformationControllerTest.php b/tests/Fortify/ProfileInformationControllerTest.php index 685e174cd2..3d5e8e0877 100644 --- a/tests/Fortify/ProfileInformationControllerTest.php +++ b/tests/Fortify/ProfileInformationControllerTest.php @@ -4,15 +4,20 @@ namespace Hypervel\Tests\Fortify; -use Hypervel\Contracts\Auth\Authenticatable; use Hypervel\Fortify\Contracts\UpdatesUserProfileInformation; -use Mockery as m; +use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Testbench\Attributes\WithMigration; +use Workbench\App\Models\User; +use Workbench\Database\Factories\UserFactory; +#[WithMigration] class ProfileInformationControllerTest extends TestCase { + use RefreshDatabase; + public function testContactInformationCanBeUpdated(): void { - $user = m::mock(Authenticatable::class); + $user = User::forceCreate(UserFactory::new()->raw()); $this->mock(UpdatesUserProfileInformation::class) ->shouldReceive('update') @@ -30,7 +35,7 @@ public function testEmailAddressWillBeUpdatedCaseInsensitive(): void { $this->app->make('config')->set('fortify.lowercase_usernames', true); - $user = m::mock(Authenticatable::class); + $user = User::forceCreate(UserFactory::new()->raw()); $this->mock(UpdatesUserProfileInformation::class) ->shouldReceive('update') diff --git a/tests/Fortify/RegisteredUserControllerTest.php b/tests/Fortify/RegisteredUserControllerTest.php index 8998daa42c..bf0ece28a4 100644 --- a/tests/Fortify/RegisteredUserControllerTest.php +++ b/tests/Fortify/RegisteredUserControllerTest.php @@ -4,20 +4,22 @@ namespace Hypervel\Tests\Fortify; -use Hypervel\Contracts\Auth\Authenticatable; -use Hypervel\Contracts\Auth\Factory as AuthFactory; -use Hypervel\Contracts\Auth\StatefulGuard; use Hypervel\Fortify\Contracts\CreatesNewUsers; -use Hypervel\Fortify\Contracts\RegisterViewResponse; -use Mockery as m; - +use Hypervel\Fortify\Fortify; +use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Support\Facades\Auth; +use Hypervel\Testbench\Attributes\WithMigration; +use Workbench\App\Models\User; +use Workbench\Database\Factories\UserFactory; + +#[WithMigration] class RegisteredUserControllerTest extends TestCase { + use RefreshDatabase; + public function testTheRegisterViewIsReturned(): void { - $this->mock(RegisterViewResponse::class) - ->shouldReceive('toResponse') - ->andReturn(response('hello world')); + Fortify::registerView(fn (): string => 'hello world'); $response = $this->get('/register'); @@ -29,27 +31,25 @@ public function testUsersCanBeCreated(): void { $this->mock(CreatesNewUsers::class) ->shouldReceive('create') - ->andReturn(m::mock(Authenticatable::class)); - - $this->expectsGuardLogin(); + ->andReturn($user = User::forceCreate(UserFactory::new()->raw())); $response = $this->post('/register', []); $response->assertRedirect('/home'); + $this->assertAuthenticatedAs($user); } public function testUsersCanBeCreatedAndRedirectedToIntendedUrl(): void { $this->mock(CreatesNewUsers::class) ->shouldReceive('create') - ->andReturn(m::mock(Authenticatable::class)); - - $this->expectsGuardLogin(); + ->andReturn($user = User::forceCreate(UserFactory::new()->raw())); $response = $this->withSession(['url.intended' => 'http://foo.com/bar']) ->post('/register', []); $response->assertRedirect('http://foo.com/bar'); + $this->assertAuthenticatedAs($user); } public function testUsernamesWillBeStoredCaseInsensitive(): void @@ -63,9 +63,7 @@ public function testUsernamesWillBeStoredCaseInsensitive(): void 'password' => 'password', ]) ->once() - ->andReturn(m::mock(Authenticatable::class)); - - $this->expectsGuardLogin(); + ->andReturn($user = User::forceCreate(UserFactory::new()->raw())); $response = $this->post('/register', [ 'email' => 'TAYLOR@LARAVEL.COM', @@ -73,6 +71,7 @@ public function testUsernamesWillBeStoredCaseInsensitive(): void ]); $response->assertRedirect('/home'); + $this->assertAuthenticatedAs($user); } public function testUsersCanBeCreatedWithRememberOption(): void @@ -80,9 +79,7 @@ public function testUsersCanBeCreatedWithRememberOption(): void $this->mock(CreatesNewUsers::class) ->shouldReceive('create') ->once() - ->andReturn(m::mock(Authenticatable::class)); - - $this->expectsGuardLogin(remember: true); + ->andReturn($user = User::forceCreate(UserFactory::new()->raw())); $response = $this->post('/register', [ 'email' => 'taylor@laravel.com', @@ -91,20 +88,7 @@ public function testUsersCanBeCreatedWithRememberOption(): void ]); $response->assertRedirect('/home'); - } - - private function expectsGuardLogin(bool $remember = false): void - { - $guard = m::mock(StatefulGuard::class); - $guard->shouldReceive('login') - ->with(m::type(Authenticatable::class), $remember) - ->once(); - - $auth = m::mock(AuthFactory::class); - $auth->shouldReceive('guard') - ->with(null) - ->andReturn($guard); - - $this->app->instance(AuthFactory::class, $auth); + $this->assertAuthenticatedAs($user); + $response->assertCookie(Auth::guard()->getRecallerName()); } } diff --git a/tests/Fortify/TestCase.php b/tests/Fortify/TestCase.php index 71e2f2c2aa..eb8841c463 100644 --- a/tests/Fortify/TestCase.php +++ b/tests/Fortify/TestCase.php @@ -13,7 +13,6 @@ use Hypervel\Support\Facades\Schema; use Hypervel\Testbench\TestCase as TestbenchTestCase; use Hypervel\Tests\Fortify\Fixtures\Admin; -use Workbench\App\Models\User; abstract class TestCase extends TestbenchTestCase { @@ -34,7 +33,7 @@ protected function getPackageProviders(ApplicationContract $app): array protected function defineEnvironment(ApplicationContract $app): void { $config = $app->make(Config::class); - $userModel = $config->get('auth.providers.users.model', User::class); + $userModel = $config->string('auth.providers.users.model'); $config->set([ 'app.key' => 'base64:' . base64_encode(str_repeat('a', 32)), @@ -87,6 +86,9 @@ protected function afterRefreshingDatabase(): void }); } + /** + * Create the admins table. + */ protected function createAdminsTable(): void { Schema::create('admins', function (Blueprint $table): void { diff --git a/tests/Fortify/VerifyEmailControllerTest.php b/tests/Fortify/VerifyEmailControllerTest.php index f777b65940..bb47ed7804 100644 --- a/tests/Fortify/VerifyEmailControllerTest.php +++ b/tests/Fortify/VerifyEmailControllerTest.php @@ -4,12 +4,12 @@ namespace Hypervel\Tests\Fortify; -use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Auth\Events\Verified; use Hypervel\Foundation\Http\FormRequest; use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Support\Facades\Event; use Hypervel\Support\Facades\URL; use Hypervel\Testbench\Attributes\WithMigration; -use Mockery as m; use Workbench\App\Models\User; use Workbench\Database\Factories\UserFactory; @@ -50,71 +50,72 @@ protected function assertEmailCanBeVerified(): void ->get($url); $response->assertRedirect('http://foo.com/bar'); + $this->assertTrue($user->fresh()->hasVerifiedEmail()); } public function testRedirectedIfEmailIsAlreadyVerified(): void { + Event::fake([Verified::class]); + + $user = User::forceCreate(UserFactory::new()->raw([ + 'email' => 'taylor@hypervel.org', + ])); + $url = URL::temporarySignedRoute( 'verification.verify', now()->addMinutes(60), [ - 'id' => 1, - 'hash' => sha1('taylor@laravel.com'), + 'id' => $user->getKey(), + 'hash' => sha1('taylor@hypervel.org'), ] ); - $user = m::mock(Authenticatable::class); - $user->shouldReceive('getKey')->andReturn(1); - $user->shouldReceive('getAuthIdentifier')->andReturn(1); - $user->shouldReceive('getEmailForVerification')->andReturn('taylor@laravel.com'); - $user->shouldReceive('hasVerifiedEmail')->andReturn(true); - $user->shouldReceive('markEmailAsVerified')->never(); - $response = $this->actingAs($user)->get($url); $response->assertStatus(302); + Event::assertNotDispatched(Verified::class); } public function testEmailIsNotVerifiedIfIdDoesNotMatch(): void { + $user = $this->createUnverifiedUser([ + 'email' => 'taylor@hypervel.org', + ]); + $url = URL::temporarySignedRoute( 'verification.verify', now()->addMinutes(60), [ - 'id' => 2, - 'hash' => sha1('taylor@laravel.com'), + 'id' => $user->getKey() + 1, + 'hash' => sha1('taylor@hypervel.org'), ] ); - $user = m::mock(Authenticatable::class); - $user->shouldReceive('getKey')->andReturn(1); - $user->shouldReceive('getAuthIdentifier')->andReturn(1); - $user->shouldReceive('getEmailForVerification')->andReturn('taylor@laravel.com'); - $response = $this->actingAs($user)->get($url); $response->assertStatus(403); + $this->assertFalse($user->fresh()->hasVerifiedEmail()); } public function testEmailIsNotVerifiedIfEmailDoesNotMatch(): void { + $user = $this->createUnverifiedUser([ + 'email' => 'taylor@hypervel.org', + ]); + $url = URL::temporarySignedRoute( 'verification.verify', now()->addMinutes(60), [ - 'id' => 1, - 'hash' => sha1('abigail@laravel.com'), + 'id' => $user->getKey(), + 'hash' => sha1('abigail@hypervel.org'), ] ); - $user = m::mock(Authenticatable::class); - $user->shouldReceive('getKey')->andReturn(1); - $user->shouldReceive('getAuthIdentifier')->andReturn(1); - $user->shouldReceive('getEmailForVerification')->andReturn('taylor@laravel.com'); - $response = $this->actingAs($user)->get($url); $response->assertStatus(403); + $this->assertFalse($user->fresh()->hasVerifiedEmail()); } /** From a075d2df06c35eced13a9bc94c82bcd24baf74f0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:08:50 +0000 Subject: [PATCH 08/33] Record Fortify sync and the Passkeys starting checkpoint Fortify is reviewed through c456642584c102f6a6aafee9932299542da637b5. The initial Passkeys commit 75e7c69373123c60f34a03ec2e13438e5d2ee360 contains only the unused package template, tooling, and project metadata. Its example source and test are not part of the Passkeys implementation; subsequent commits remain to be reconciled. --- docs/upstream-sync/sync.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml index d2be4197db..8c2de00dbe 100644 --- a/docs/upstream-sync/sync.yaml +++ b/docs/upstream-sync/sync.yaml @@ -54,15 +54,15 @@ laravel/facade-documenter: laravel/fortify: branch: 1.x - checked_through: null - last_reviewed_pr: null - sync_date: null + checked_through: c456642584c102f6a6aafee9932299542da637b5 + last_reviewed_pr: 703 + sync_date: '2026-10-02' laravel/passkeys-server: branch: main - checked_through: null + checked_through: 75e7c69373123c60f34a03ec2e13438e5d2ee360 last_reviewed_pr: null - sync_date: null + sync_date: '2026-10-02' notes: Composer package is laravel/passkeys. laravel/sanctum: From 252cd054dd2516159c4b8b0a4212d2ebe25deedd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:11:26 +0000 Subject: [PATCH 09/33] Share one session key for passkey verification options laravel/passkeys-server stores login and confirmation options under one passkey.verification_options session key, which the parameterless PasskeyVerificationRequest::verificationOptions() reads. Hypervel had split them into separate login and confirmation keys and made callers pass the key, changing the signature of a public request method. Both controllers and the request now use upstream's key and method. The passkey controllers also go back to upstream's app() response resolution instead of an injected container, and registration reads the name with toString() as upstream does, since ported code keeps its upstream style. Upstream reference: laravel/passkeys-server main at 4f81dfd5f7, including direct commits 7280c5bdaf and 208d206347. Validation: each changed test file, the Passkeys and Fortify suites, formatting and PHPStan pass. --- .../Controllers/PasskeyConfirmationController.php | 12 +++--------- .../src/Http/Controllers/PasskeyLoginController.php | 12 +++--------- .../Controllers/PasskeyRegistrationController.php | 12 +++--------- .../src/Http/Requests/PasskeyVerificationRequest.php | 4 ++-- .../Feature/Controllers/PasskeyConfirmationTest.php | 6 +++--- .../Controllers/PasskeyLoginControllerTest.php | 6 +++--- .../Feature/Controllers/PasskeyLoginTest.php | 6 +++--- 7 files changed, 20 insertions(+), 38 deletions(-) diff --git a/src/passkeys/src/Http/Controllers/PasskeyConfirmationController.php b/src/passkeys/src/Http/Controllers/PasskeyConfirmationController.php index 231eef1005..fe4d036327 100644 --- a/src/passkeys/src/Http/Controllers/PasskeyConfirmationController.php +++ b/src/passkeys/src/Http/Controllers/PasskeyConfirmationController.php @@ -5,7 +5,6 @@ namespace Hypervel\Passkeys\Http\Controllers; use Hypervel\Auth\AuthenticationException; -use Hypervel\Contracts\Container\Container; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Hypervel\Passkeys\Actions\GenerateVerificationOptions; @@ -21,11 +20,6 @@ class PasskeyConfirmationController extends Controller { - public function __construct( - private readonly Container $container, - ) { - } - /** * Get passkey confirmation options for the authenticated user. */ @@ -42,7 +36,7 @@ public function index(Request $request, GenerateVerificationOptions $generate): $serialized = WebAuthn::toJson($options); - $request->session()->put('passkey.confirmation_options', $serialized); + $request->session()->put('passkey.verification_options', $serialized); return response()->json([ 'options' => WebAuthn::toBrowserArray($options), @@ -67,7 +61,7 @@ public function store( $verify( $request->credential(), - $request->verificationOptions('passkey.confirmation_options'), + $request->verificationOptions(), $user ); @@ -76,6 +70,6 @@ public function store( $session->passwordConfirmed($guardName); - return $this->container->make(PasskeyConfirmationResponse::class); + return app(PasskeyConfirmationResponse::class); } } diff --git a/src/passkeys/src/Http/Controllers/PasskeyLoginController.php b/src/passkeys/src/Http/Controllers/PasskeyLoginController.php index ac24f80301..59f6af51b3 100644 --- a/src/passkeys/src/Http/Controllers/PasskeyLoginController.php +++ b/src/passkeys/src/Http/Controllers/PasskeyLoginController.php @@ -4,7 +4,6 @@ namespace Hypervel\Passkeys\Http\Controllers; -use Hypervel\Contracts\Container\Container; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Hypervel\Passkeys\Actions\GenerateVerificationOptions; @@ -19,11 +18,6 @@ class PasskeyLoginController extends Controller { - public function __construct( - private readonly Container $container, - ) { - } - /** * Get passkey login options. */ @@ -33,7 +27,7 @@ public function index(Request $request, GenerateVerificationOptions $generate): $serialized = WebAuthn::toJson($options); - $request->session()->put('passkey.login_options', $serialized); + $request->session()->put('passkey.verification_options', $serialized); return response()->json([ 'options' => WebAuthn::toBrowserArray($options), @@ -49,7 +43,7 @@ public function store( ): PasskeyLoginResponse { $passkey = $verify( $request->credential(), - $request->verificationOptions('passkey.login_options') + $request->verificationOptions() ); $user = $passkey->user; @@ -62,6 +56,6 @@ public function store( $request->session()->regenerate(); - return $this->container->make(PasskeyLoginResponse::class); + return app(PasskeyLoginResponse::class); } } diff --git a/src/passkeys/src/Http/Controllers/PasskeyRegistrationController.php b/src/passkeys/src/Http/Controllers/PasskeyRegistrationController.php index 4bd1cb1923..8cc79c5233 100644 --- a/src/passkeys/src/Http/Controllers/PasskeyRegistrationController.php +++ b/src/passkeys/src/Http/Controllers/PasskeyRegistrationController.php @@ -5,7 +5,6 @@ namespace Hypervel\Passkeys\Http\Controllers; use Hypervel\Auth\AuthenticationException; -use Hypervel\Contracts\Container\Container; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Hypervel\Passkeys\Actions\DeletePasskey; @@ -21,11 +20,6 @@ class PasskeyRegistrationController extends Controller { - public function __construct( - private readonly Container $container, - ) { - } - /** * Get passkey registration options for the authenticated user. */ @@ -57,12 +51,12 @@ public function store( $passkey = $storePasskey( $user, - (string) $request->string('name'), + $request->string('name')->toString(), $request->credential(), $request->registrationOptions() ); - return $this->container->make(PasskeyRegistrationResponse::class)->withPasskey($passkey); + return app(PasskeyRegistrationResponse::class)->withPasskey($passkey); } /** @@ -77,6 +71,6 @@ public function destroy( $deletePasskey($user, $passkey); - return $this->container->make(PasskeyDeletedResponse::class); + return app(PasskeyDeletedResponse::class); } } diff --git a/src/passkeys/src/Http/Requests/PasskeyVerificationRequest.php b/src/passkeys/src/Http/Requests/PasskeyVerificationRequest.php index 2956b16d40..9307013a19 100644 --- a/src/passkeys/src/Http/Requests/PasskeyVerificationRequest.php +++ b/src/passkeys/src/Http/Requests/PasskeyVerificationRequest.php @@ -84,10 +84,10 @@ public function remember(): bool * * @throws ValidationException */ - public function verificationOptions(string $sessionKey): PublicKeyCredentialRequestOptions + public function verificationOptions(): PublicKeyCredentialRequestOptions { /** @var null|string $serialized */ - $serialized = $this->session()->pull($sessionKey); + $serialized = $this->session()->pull('passkey.verification_options'); if (! is_string($serialized) || $serialized === '') { throw ValidationException::withMessages([ diff --git a/tests/Passkeys/Feature/Controllers/PasskeyConfirmationTest.php b/tests/Passkeys/Feature/Controllers/PasskeyConfirmationTest.php index a0ba752da2..efd54bfadd 100644 --- a/tests/Passkeys/Feature/Controllers/PasskeyConfirmationTest.php +++ b/tests/Passkeys/Feature/Controllers/PasskeyConfirmationTest.php @@ -48,7 +48,7 @@ public function testItStoresConfirmationOptionsInSession(): void ->getJson('/passkeys/confirm/options') ->assertOk(); - $this->assertNotNull(session('passkey.confirmation_options')); + $this->assertNotNull(session('passkey.verification_options')); } public function testItRequiresAuthenticationForConfirmationOptions(): void @@ -84,7 +84,7 @@ public function testItReturnsValidationErrorWhenPasskeyConfirmationIsInvalid(): ->getMock()); $this->actingAs($user) - ->withSession(['passkey.confirmation_options' => WebAuthn::toJson($this->createRequestOptions())]) + ->withSession(['passkey.verification_options' => WebAuthn::toJson($this->createRequestOptions())]) ->postJson('/passkeys/confirm', [ 'credential' => $this->createAssertionCredential(), ]) @@ -116,7 +116,7 @@ public function testItMarksThePasswordAsConfirmedWhenPasskeyConfirmationSucceeds ->getMock()); $this->actingAs($user) - ->withSession(['passkey.confirmation_options' => WebAuthn::toJson($this->createRequestOptions())]) + ->withSession(['passkey.verification_options' => WebAuthn::toJson($this->createRequestOptions())]) ->postJson('/passkeys/confirm', [ 'credential' => $this->createAssertionCredential(), ]) diff --git a/tests/Passkeys/Feature/Controllers/PasskeyLoginControllerTest.php b/tests/Passkeys/Feature/Controllers/PasskeyLoginControllerTest.php index eeb7d8b94c..2c0eaf3603 100644 --- a/tests/Passkeys/Feature/Controllers/PasskeyLoginControllerTest.php +++ b/tests/Passkeys/Feature/Controllers/PasskeyLoginControllerTest.php @@ -38,7 +38,7 @@ public function testItDoesNotLogInWhenCustomSignInAuthorizationCallbackReturnsFa Passkeys::authorizeLoginUsing(static fn (): bool => false); - $this->withSession(['passkey.login_options' => WebAuthn::toJson($this->createRequestOptions())]) + $this->withSession(['passkey.verification_options' => WebAuthn::toJson($this->createRequestOptions())]) ->postJson('/passkeys/login', ['credential' => $this->createAssertionCredential()]) ->assertUnprocessable(); @@ -70,7 +70,7 @@ public function testItReturnsTheCustomSignInAuthorizationValidationMessage(): vo ]); }); - $this->withSession(['passkey.login_options' => WebAuthn::toJson($this->createRequestOptions())]) + $this->withSession(['passkey.verification_options' => WebAuthn::toJson($this->createRequestOptions())]) ->postJson('/passkeys/login', ['credential' => $this->createAssertionCredential()]) ->assertUnprocessable() ->assertJsonValidationErrors(['credential' => 'This account has been suspended.']); @@ -99,7 +99,7 @@ public function testItLogsInWhenCustomSignInAuthorizationCallbackReturnsTrue(): Passkeys::authorizeLoginUsing(static fn (): bool => true); - $this->withSession(['passkey.login_options' => WebAuthn::toJson($this->createRequestOptions())]) + $this->withSession(['passkey.verification_options' => WebAuthn::toJson($this->createRequestOptions())]) ->postJson('/passkeys/login', [ 'credential' => $this->createAssertionCredential(), 'remember' => true, diff --git a/tests/Passkeys/Feature/Controllers/PasskeyLoginTest.php b/tests/Passkeys/Feature/Controllers/PasskeyLoginTest.php index 38dde65980..7401a03ef4 100644 --- a/tests/Passkeys/Feature/Controllers/PasskeyLoginTest.php +++ b/tests/Passkeys/Feature/Controllers/PasskeyLoginTest.php @@ -34,7 +34,7 @@ public function testItStoresLoginOptionsInSession(): void { $this->getJson('/passkeys/login/options')->assertOk(); - $this->assertNotNull(session('passkey.login_options')); + $this->assertNotNull(session('passkey.verification_options')); } public function testItReturnsValidationErrorWhenPasskeyIsInvalid(): void @@ -45,7 +45,7 @@ public function testItReturnsValidationErrorWhenPasskeyIsInvalid(): void ->andThrow(InvalidPasskeyException::make('Unable to verify passkey. Please try again.')) ->getMock()); - $this->withSession(['passkey.login_options' => WebAuthn::toJson($this->createRequestOptions())]) + $this->withSession(['passkey.verification_options' => WebAuthn::toJson($this->createRequestOptions())]) ->postJson('/passkeys/login', [ 'credential' => $this->createAssertionCredential(), ]) @@ -57,7 +57,7 @@ public function testItReturnsValidationErrorWhenPasskeyIsInvalid(): void public function testItReturnsValidationErrorWhenCredentialFormatIsInvalid(): void { - $this->withSession(['passkey.login_options' => WebAuthn::toJson($this->createRequestOptions())]) + $this->withSession(['passkey.verification_options' => WebAuthn::toJson($this->createRequestOptions())]) ->postJson('/passkeys/login', [ 'credential' => [ 'id' => 'dGVzdC1pZA', From 888a51263695ca01f26f47b2938797ffc113cdcb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:11:38 +0000 Subject: [PATCH 10/33] Restore upstream's passkey lookup signature laravel/passkeys-server PR 12 made VerifyPasskey::getPasskey() a public extension point taking the credential and a lock flag. Hypervel had added a third owner type parameter so it could scope passwordless lookups to the selected guard's provider. An application override written against upstream's signature then fails with a fatal error. The lookup goes back to upstream's signature, and resolvePasskeyOwner() compares the stored owner type with the provider's before resolving the relation. Passkeys owned by another provider's model, or by a type that can't be resolved, are still rejected without loading that owner. PR 15 runs verification in a transaction with a row lock. Hypervel keeps its adaptation of running that transaction on the passkey model's own connection, so the lock holds when passkeys live on a non-default connection. It now uses the DB facade as upstream does instead of an injected connection resolver. The connection test now runs against a real SQLite connection and checks that the lookup happens inside its transaction. Upstream reference: laravel/passkeys-server main at 4f81dfd5f7. Validation: each changed test file, the Passkeys and Fortify suites, formatting and PHPStan pass. --- src/passkeys/src/Actions/VerifyPasskey.php | 27 +++---- .../Feature/Actions/VerifyPasskeyTest.php | 38 ++++------ tests/Passkeys/PasskeysGuardTest.php | 72 +++++++------------ 3 files changed, 53 insertions(+), 84 deletions(-) diff --git a/src/passkeys/src/Actions/VerifyPasskey.php b/src/passkeys/src/Actions/VerifyPasskey.php index cdbc4e2813..92a0d32405 100644 --- a/src/passkeys/src/Actions/VerifyPasskey.php +++ b/src/passkeys/src/Actions/VerifyPasskey.php @@ -6,7 +6,6 @@ use Hypervel\Auth\EloquentUserProvider; use Hypervel\Contracts\Auth\StatefulGuard; -use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Passkeys\Concerns\DispatchesEvents; use Hypervel\Passkeys\Contracts\PasskeyUser; use Hypervel\Passkeys\Events\PasskeyVerified; @@ -15,6 +14,7 @@ use Hypervel\Passkeys\Passkeys; use Hypervel\Passkeys\Support\WebAuthn; use Hypervel\Support\Facades\Date; +use Hypervel\Support\Facades\DB; use ParagonIE\ConstantTime\Base64UrlSafe; use RuntimeException; use Webauthn\AuthenticatorAssertionResponse; @@ -27,11 +27,6 @@ class VerifyPasskey { use DispatchesEvents; - public function __construct( - private readonly ConnectionResolverInterface $database, - ) { - } - /** * Validate the passkey credential and return the passkey. * @@ -48,11 +43,10 @@ public function __invoke( ? null : $this->ownerMorphClassForGuard(Passkeys::guard()); $passkeyModel = Passkeys::passkeyModel(); - /** @var Passkey $passkeyInstance */ - $passkeyInstance = new $passkeyModel; - return $this->database->connection($passkeyInstance->getConnectionName())->transaction(function () use ($credential, $options, $user, $response, $ownerType): Passkey { - $passkey = $this->getPasskey($credential, lock: true, ownerType: $ownerType); + // The row lock only holds inside a transaction on the passkey model's own connection. + return DB::connection((new $passkeyModel)->getConnectionName())->transaction(function () use ($credential, $options, $user, $response, $ownerType): Passkey { + $passkey = $this->getPasskey($credential, lock: true); $this->ensurePasskeyBelongsToUser($passkey, $user); @@ -95,7 +89,7 @@ protected function getResponse(PublicKeyCredential $credential): AuthenticatorAs * * @throws InvalidPasskeyException */ - public function getPasskey(PublicKeyCredential $credential, bool $lock = false, ?string $ownerType = null): Passkey + public function getPasskey(PublicKeyCredential $credential, bool $lock = false): Passkey { // Assertion ceremonies do not run CheckCredentialId, so enforce its CTAP2 limit before lookup. if (strlen($credential->rawId) > 1023) { @@ -107,10 +101,6 @@ public function getPasskey(PublicKeyCredential $credential, bool $lock = false, $query = $passkeyModel::query()->where('credential_id', $credentialId); - if ($ownerType !== null) { - $query->where('user_type', $ownerType); - } - if ($lock) { $query->lockForUpdate(); } @@ -151,9 +141,14 @@ public function ensurePasskeyBelongsToUser(Passkey $passkey, ?PasskeyUser $user) */ protected function resolvePasskeyOwner(Passkey $passkey, string $ownerType): PasskeyUser { + // Compare the stored type first so another provider's owner type is never resolved. + if ($passkey->user_type !== $ownerType) { + throw InvalidPasskeyException::make('Passkey not recognized. It may have been removed from your account.'); + } + $user = $passkey->user; - if (! $user instanceof PasskeyUser || $user->getMorphClass() !== $ownerType) { + if (! $user instanceof PasskeyUser) { throw InvalidPasskeyException::make('Passkey not recognized. It may have been removed from your account.'); } diff --git a/tests/Passkeys/Feature/Actions/VerifyPasskeyTest.php b/tests/Passkeys/Feature/Actions/VerifyPasskeyTest.php index 262818b434..13f077d74c 100644 --- a/tests/Passkeys/Feature/Actions/VerifyPasskeyTest.php +++ b/tests/Passkeys/Feature/Actions/VerifyPasskeyTest.php @@ -4,10 +4,7 @@ namespace Hypervel\Tests\Passkeys\Feature\Actions; -use Closure; use Hypervel\Contracts\Events\Dispatcher; -use Hypervel\Database\ConnectionInterface; -use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Passkeys\Actions\VerifyPasskey; use Hypervel\Passkeys\Events\PasskeyVerified; use Hypervel\Passkeys\Exceptions\InvalidPasskeyException; @@ -16,6 +13,7 @@ use Hypervel\Passkeys\Support\WebAuthn; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Event; +use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Tests\Passkeys\Fixtures\User; use Hypervel\Tests\Passkeys\Fixtures\WebAuthnFixtures; use Hypervel\Tests\Passkeys\TestCase; @@ -69,9 +67,7 @@ public function testItVerifiesAPasskeyAndReturnsIt(): void $updatedSource = $this->createCredentialSource($userHandle, $credentialId, counter: 6); - $action = m::mock(VerifyPasskey::class, [ - app(ConnectionResolverInterface::class), - ]) + $action = m::mock(VerifyPasskey::class) ->makePartial() ->shouldAllowMockingProtectedMethods() ->shouldReceive('validate') @@ -176,9 +172,7 @@ public function testItThrowsExceptionWhenPasskeyDoesNotBelongToExpectedUser(): v response: m::mock(AuthenticatorAssertionResponse::class), ); - $action = m::mock(VerifyPasskey::class, [ - app(ConnectionResolverInterface::class), - ]) + $action = m::mock(VerifyPasskey::class) ->makePartial() ->shouldAllowMockingProtectedMethods() ->shouldReceive('validate') @@ -315,7 +309,7 @@ public function testItUsesTheRelyingPartyIdStoredInVerificationOptions(): void rpId: 'registered.example.com', ); - $action = new ExposesVerifyPasskeyHost(app(ConnectionResolverInterface::class)); + $action = new ExposesVerifyPasskeyHost; $this->assertSame('registered.example.com', $action->host($options)); } @@ -326,7 +320,7 @@ public function testItThrowsWhenVerificationOptionsHaveNoRelyingPartyId(): void challenge: random_bytes(32), ); - $action = new ExposesVerifyPasskeyHost(app(ConnectionResolverInterface::class)); + $action = new ExposesVerifyPasskeyHost; $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Passkey verification options must contain a relying party ID.'); @@ -334,6 +328,11 @@ public function testItThrowsWhenVerificationOptionsHaveNoRelyingPartyId(): void $action->host($options); } + #[WithConfig('database.connections.passkeys', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'foreign_key_constraints' => false, + ])] public function testItUsesTheConfiguredPasskeyModelConnectionForVerificationTransactions(): void { Passkeys::usePasskeyModel(CustomConnectionPasskey::class); @@ -352,14 +351,6 @@ public function testItUsesTheConfiguredPasskeyModelConnectionForVerificationTran 'credential' => ['id' => 'credential-connection'], ]); - $database = m::mock(ConnectionResolverInterface::class); - $connection = m::mock(ConnectionInterface::class); - $database->shouldReceive('connection')->once()->with('passkeys')->andReturn($connection); - $connection->shouldReceive('transaction') - ->once() - ->with(m::type(Closure::class)) - ->andReturnUsing(static fn (Closure $callback): Passkey => $callback()); - $events = m::mock(Dispatcher::class)->shouldIgnoreMissing(); $events->shouldReceive('hasListeners')->withAnyArgs()->andReturnFalse()->byDefault(); $events->shouldReceive('hasListeners')->once()->with(PasskeyVerified::class)->andReturnFalse(); @@ -380,13 +371,13 @@ public function testItUsesTheConfiguredPasskeyModelConnectionForVerificationTran ); $verifier = new ConnectionAwareVerifyPasskey( - $database, $passkey, $this->createStub(AuthenticatorAssertionResponse::class), ); $this->assertSame($passkey, $verifier($credential, $options, $user)); $this->assertTrue($verifier->receivedLockedLookup); + $this->assertSame(1, $verifier->lookupTransactionLevel); } /** @@ -436,12 +427,12 @@ class ConnectionAwareVerifyPasskey extends VerifyPasskey { public bool $receivedLockedLookup = false; + public ?int $lookupTransactionLevel = null; + public function __construct( - ConnectionResolverInterface $database, private readonly Passkey $passkey, private readonly AuthenticatorAssertionResponse $response, ) { - parent::__construct($database); } /** @@ -455,9 +446,10 @@ protected function getResponse(PublicKeyCredential $credential): AuthenticatorAs /** * Get the passkey by credential ID. */ - public function getPasskey(PublicKeyCredential $credential, bool $lock = false, ?string $ownerType = null): Passkey + public function getPasskey(PublicKeyCredential $credential, bool $lock = false): Passkey { $this->receivedLockedLookup = $lock; + $this->lookupTransactionLevel = DB::connection('passkeys')->transactionLevel(); return $this->passkey; } diff --git a/tests/Passkeys/PasskeysGuardTest.php b/tests/Passkeys/PasskeysGuardTest.php index 9cd3e6ba09..c0ef47b9c7 100644 --- a/tests/Passkeys/PasskeysGuardTest.php +++ b/tests/Passkeys/PasskeysGuardTest.php @@ -7,18 +7,17 @@ use Hypervel\Auth\EloquentUserProvider; use Hypervel\Contracts\Auth\Factory as AuthFactory; use Hypervel\Contracts\Auth\StatefulGuard; -use Hypervel\Database\Schema\Blueprint; use Hypervel\Passkeys\Actions\VerifyPasskey; use Hypervel\Passkeys\Exceptions\InvalidPasskeyException; use Hypervel\Passkeys\Passkey; use Hypervel\Passkeys\Passkeys; -use Hypervel\Support\Facades\Schema; use Hypervel\Tests\Passkeys\Fixtures\Admin; use Hypervel\Tests\Passkeys\Fixtures\User; use ParagonIE\ConstantTime\Base64UrlSafe; -use ReflectionMethod; -use Webauthn\AuthenticatorResponse; +use PHPUnit\Framework\Attributes\DataProvider; +use Webauthn\AuthenticatorAssertionResponse; use Webauthn\PublicKeyCredential; +use Webauthn\PublicKeyCredentialRequestOptions; class PasskeysGuardTest extends TestCase { @@ -36,10 +35,10 @@ public function testPasskeysGuardFollowsCurrentDefaultGuardSelectedByShouldUse() $this->assertInstanceOf(StatefulGuard::class, Passkeys::guard()); } - public function testSelectedGuardProviderScopesPasswordlessPasskeyLookup(): void + #[DataProvider('ownerTypesOutsideTheSelectedProvider')] + public function testSelectedGuardProviderScopesPasswordlessPasskeyVerification(string $ownerType): void { $this->configureAdminGuard(); - $this->createAdminsTable(); /** @var AuthFactory $auth */ $auth = $this->app->make(AuthFactory::class); @@ -49,37 +48,44 @@ public function testSelectedGuardProviderScopesPasswordlessPasskeyLookup(): void 'name' => 'User', 'email' => 'user@example.com', ]); - $admin = Admin::create([ - 'name' => 'Admin', - 'email' => 'admin@example.com', - ]); $rawCredentialId = random_bytes(32); $credentialId = Base64UrlSafe::encodeUnpadded($rawCredentialId); - /** @var Passkey $passkey */ - $passkey = $user->passkeys()->create([ + (new Passkey)->forceFill([ + 'user_type' => $ownerType, + 'user_id' => $user->getKey(), 'name' => 'User key', 'credential_id' => $credentialId, 'credential' => ['id' => $credentialId], - ]); + ])->save(); $credential = PublicKeyCredential::create( 'public-key', $rawCredentialId, - $this->createStub(AuthenticatorResponse::class), + $this->createStub(AuthenticatorAssertionResponse::class), ); - $verifier = new VerifyPasskey($this->app->make('db')); - $selectedOwnerMorphClass = $this->selectedOwnerMorphClass($verifier); - - $this->assertSame($admin->getMorphClass(), $selectedOwnerMorphClass); - $this->assertNotSame($passkey->user_type, $selectedOwnerMorphClass); - $this->expectException(InvalidPasskeyException::class); $this->expectExceptionMessage('Passkey not recognized. It may have been removed from your account.'); - $verifier->getPasskey($credential, ownerType: $selectedOwnerMorphClass); + app(VerifyPasskey::class)($credential, PublicKeyCredentialRequestOptions::create( + challenge: random_bytes(32), + rpId: 'localhost', + )); + } + + /** + * Get stored owner types that the admin guard provider does not own. + * + * @return array + */ + public static function ownerTypesOutsideTheSelectedProvider(): array + { + return [ + 'another provider model' => [User::class], + 'unresolvable owner type' => ['Missing\PasskeyOwner'], + ]; } /** @@ -108,28 +114,4 @@ private function configureAdminGuard(): void ], ]); } - - /** - * Create the admins table fixture. - */ - private function createAdminsTable(): void - { - Schema::create('admins', function (Blueprint $table): void { - $table->id(); - $table->string('name'); - $table->string('email')->unique(); - $table->rememberToken(); - $table->timestamps(); - }); - } - - /** - * Get the owner morph class for the selected guard. - */ - private function selectedOwnerMorphClass(VerifyPasskey $verifier): string - { - $method = new ReflectionMethod($verifier, 'ownerMorphClassForGuard'); - - return $method->invoke($verifier, Passkeys::guard()); - } } From 3bf2c53c67a8ebedcff9269f057134bab5ee1b61 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:11:55 +0000 Subject: [PATCH 11/33] Mark the passkey registration response as transient laravel/passkeys-server's PasskeyRegistrationResponse::withPasskey() stores the registered passkey on the response and returns it. Hypervel returned a modified clone instead, because resolving the concrete class directly gives a worker-lifetime auto-singleton that would carry one request's passkey into another. Overrides written against upstream that call withPasskey() without using its return value would then render no passkey. The response holds per-registration state, so it is now Transient and every resolution is fresh. withPasskey() goes back to upstream's in-place setter, and the passkey is again upstream's protected property rather than a private constructor parameter, so subclasses can read it. The test resolves both the contract and the concrete class twice and checks that each response renders only its own passkey. Upstream reference: laravel/passkeys-server main at 4f81dfd5f7, including direct commit 7280c5bdaf. Validation: each changed test file, the Passkeys and Fortify suites, formatting and PHPStan pass. --- .../Responses/PasskeyRegistrationResponse.php | 16 +++++----- .../PasskeyRegistrationResponseTest.php | 30 +++++++++++++++---- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/passkeys/src/Http/Responses/PasskeyRegistrationResponse.php b/src/passkeys/src/Http/Responses/PasskeyRegistrationResponse.php index 8601153e5d..331903dbbf 100644 --- a/src/passkeys/src/Http/Responses/PasskeyRegistrationResponse.php +++ b/src/passkeys/src/Http/Responses/PasskeyRegistrationResponse.php @@ -4,28 +4,28 @@ namespace Hypervel\Passkeys\Http\Responses; +use Hypervel\Contracts\Container\Transient; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Hypervel\Passkeys\Contracts\PasskeyRegistrationResponse as PasskeyRegistrationResponseContract; use Hypervel\Passkeys\Passkey; use Symfony\Component\HttpFoundation\Response; -class PasskeyRegistrationResponse implements PasskeyRegistrationResponseContract +class PasskeyRegistrationResponse implements PasskeyRegistrationResponseContract, Transient { - public function __construct( - private ?Passkey $passkey = null, - ) { - } + /** + * The passkey that was registered. + */ + protected ?Passkey $passkey = null; /** * Set the passkey that was registered. */ public function withPasskey(Passkey $passkey): static { - $response = clone $this; - $response->passkey = $passkey; + $this->passkey = $passkey; - return $response; + return $this; } /** diff --git a/tests/Passkeys/PasskeyRegistrationResponseTest.php b/tests/Passkeys/PasskeyRegistrationResponseTest.php index 2c1a492871..ca4e0bc416 100644 --- a/tests/Passkeys/PasskeyRegistrationResponseTest.php +++ b/tests/Passkeys/PasskeyRegistrationResponseTest.php @@ -5,12 +5,18 @@ namespace Hypervel\Tests\Passkeys; use Hypervel\Http\Request; +use Hypervel\Passkeys\Contracts\PasskeyRegistrationResponse as PasskeyRegistrationResponseContract; use Hypervel\Passkeys\Http\Responses\PasskeyRegistrationResponse; use Hypervel\Passkeys\Passkey; +use PHPUnit\Framework\Attributes\DataProvider; class PasskeyRegistrationResponseTest extends TestCase { - public function testWithPasskeyReturnsCloneWithoutMutatingOriginalResponse(): void + /** + * @param class-string $abstract + */ + #[DataProvider('registrationResponseAbstracts')] + public function testEachResolvedResponseRetainsItsOwnPasskey(string $abstract): void { $firstPasskey = new Passkey([ 'name' => 'First key', @@ -22,12 +28,11 @@ public function testWithPasskeyReturnsCloneWithoutMutatingOriginalResponse(): vo ]); $secondPasskey->id = 2; - $response = new PasskeyRegistrationResponse; - $firstResponse = $response->withPasskey($firstPasskey); - $secondResponse = $response->withPasskey($secondPasskey); + $firstResponse = $this->app->make($abstract); + $secondResponse = $this->app->make($abstract); - $this->assertNotSame($response, $firstResponse); - $this->assertNotSame($response, $secondResponse); + $firstResponse->withPasskey($firstPasskey); + $secondResponse->withPasskey($secondPasskey); $this->assertSame( ['status' => 'passkey-registered', 'id' => '1', 'name' => 'First key'], @@ -39,4 +44,17 @@ public function testWithPasskeyReturnsCloneWithoutMutatingOriginalResponse(): vo json_decode($secondResponse->toResponse(Request::create('/', server: ['HTTP_ACCEPT' => 'application/json']))->getContent(), true) ); } + + /** + * Get the container keys that resolve the registration response. + * + * @return array}> + */ + public static function registrationResponseAbstracts(): array + { + return [ + 'contract' => [PasskeyRegistrationResponseContract::class], + 'concrete response' => [PasskeyRegistrationResponse::class], + ]; + } } From fe16d0af59dd49985c70fd0165d7ff2662ef8ee6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:12:11 +0000 Subject: [PATCH 12/33] Delete passkeys without repeating the route's ownership check laravel/passkeys-server's DeletePasskey action deletes the passkey it is given and dispatches PasskeyDeleted for the acting user. Hypervel had added checks that the actor implements PasskeyUser and owns the passkey, throwing a 403 otherwise. The route already enforces ownership, because the {passkey} binding is scoped to the authenticated owner and returns 404 for anyone else's passkey. The extra checks only stopped supported direct calls, such as an administrator removing a user's passkey from application code. The action now matches upstream. Its tests cover deletion by an actor that does not own passkeys, including the event's user. The split package no longer uses symfony/http-kernel, so that requirement is removed from its composer.json. Upstream reference: laravel/passkeys-server main at 4f81dfd5f7, including direct commit 7280c5bdaf. Validation: each changed test file, the Passkeys and Fortify suites, formatting and PHPStan pass. --- src/passkeys/composer.json | 1 - src/passkeys/src/Actions/DeletePasskey.php | 26 -------- .../Feature/Actions/DeletePasskeyTest.php | 66 ++++--------------- tests/Passkeys/PackageMetadataTest.php | 2 - 4 files changed, 14 insertions(+), 81 deletions(-) diff --git a/src/passkeys/composer.json b/src/passkeys/composer.json index 0cff23adda..4ddc9d2b8a 100644 --- a/src/passkeys/composer.json +++ b/src/passkeys/composer.json @@ -46,7 +46,6 @@ "paragonie/constant_time_encoding": "^3.1", "symfony/console": "^8.1.2", "symfony/http-foundation": "^8.1", - "symfony/http-kernel": "^8.1", "symfony/serializer": "^8.1", "web-auth/cose-lib": "^4.8.1", "web-auth/webauthn-lib": "^5.3" diff --git a/src/passkeys/src/Actions/DeletePasskey.php b/src/passkeys/src/Actions/DeletePasskey.php index 3cee647619..87601074c4 100644 --- a/src/passkeys/src/Actions/DeletePasskey.php +++ b/src/passkeys/src/Actions/DeletePasskey.php @@ -6,11 +6,8 @@ use Hypervel\Contracts\Auth\Authenticatable; use Hypervel\Passkeys\Concerns\DispatchesEvents; -use Hypervel\Passkeys\Contracts\PasskeyUser; use Hypervel\Passkeys\Events\PasskeyDeleted; use Hypervel\Passkeys\Passkey; -use RuntimeException; -use Symfony\Component\HttpKernel\Exception\HttpException; class DeletePasskey { @@ -21,14 +18,6 @@ class DeletePasskey */ public function __invoke(Authenticatable $user, Passkey $passkey): void { - if (! $user instanceof PasskeyUser) { - throw new RuntimeException('User model must implement the PasskeyUser contract.'); - } - - if (! $this->passkeyBelongsToUser($passkey, $user)) { - throw new HttpException(403); - } - $passkey->delete(); $this->dispatchIfListening( @@ -36,19 +25,4 @@ public function __invoke(Authenticatable $user, Passkey $passkey): void static fn (): PasskeyDeleted => new PasskeyDeleted($user, $passkey), ); } - - /** - * Determine if the passkey belongs to the given user. - */ - private function passkeyBelongsToUser(Passkey $passkey, PasskeyUser $user): bool - { - $identifier = $user->getKey(); - - if (! is_scalar($identifier)) { - return false; - } - - return $passkey->user_type === $user->getMorphClass() - && (string) $passkey->user_id === (string) $identifier; - } } diff --git a/tests/Passkeys/Feature/Actions/DeletePasskeyTest.php b/tests/Passkeys/Feature/Actions/DeletePasskeyTest.php index f72b32f7b4..ece261908d 100644 --- a/tests/Passkeys/Feature/Actions/DeletePasskeyTest.php +++ b/tests/Passkeys/Feature/Actions/DeletePasskeyTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Passkeys\Feature\Actions; +use Hypervel\Auth\GenericUser; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Passkeys\Actions\DeletePasskey; use Hypervel\Passkeys\Events\PasskeyDeleted; @@ -12,7 +13,6 @@ use Hypervel\Tests\Passkeys\Fixtures\User; use Hypervel\Tests\Passkeys\TestCase; use Mockery as m; -use Symfony\Component\HttpKernel\Exception\HttpException; class DeletePasskeyTest extends TestCase { @@ -58,60 +58,27 @@ public function testItDispatchesPasskeyDeletedEvent(): void ); } - public function testItRejectsDeletingAnotherUsersPasskey(): void + public function testItDeletesAnotherUsersPasskeyForAnActorThatDoesNotOwnPasskeys(): void { - $user = User::create([ - 'name' => 'John Doe', - 'email' => 'john@example.com', - ]); - $otherUser = User::create([ - 'name' => 'Jane Doe', - 'email' => 'jane@example.com', - ]); - $passkey = $this->createPasskeyForUser($user, 'credential-other-owner'); - - try { - app(DeletePasskey::class)($otherUser, $passkey); - } catch (HttpException $exception) { - $this->assertSame(403, $exception->getStatusCode()); - $this->assertDatabaseHas('passkeys', [ - 'id' => $passkey->getKey(), - ]); - - return; - } - - $this->fail('Expected deleting another user\'s passkey to fail.'); - } + Event::fake([PasskeyDeleted::class]); - public function testItRejectsDeletingAPasskeyForTheSameKeyOnADifferentOwnerMorphClass(): void - { $user = User::create([ 'name' => 'John Doe', 'email' => 'john@example.com', ]); - $sameKeyDifferentMorphUser = AlternatePasskeyUser::create([ - 'name' => 'Jane Doe', - 'email' => 'jane@example.com', - ]); - $passkey = $this->createPasskeyForUser($user, 'credential-other-morph'); + $passkey = $this->createPasskeyForUser($user, 'credential-admin-delete'); + $administrator = new GenericUser(['id' => 99]); - $sameKeyDifferentMorphUser->forceFill([ - $sameKeyDifferentMorphUser->getKeyName() => $user->getKey(), - ]); + app(DeletePasskey::class)($administrator, $passkey); - try { - app(DeletePasskey::class)($sameKeyDifferentMorphUser, $passkey); - } catch (HttpException $exception) { - $this->assertSame(403, $exception->getStatusCode()); - $this->assertDatabaseHas('passkeys', [ - 'id' => $passkey->getKey(), - ]); - - return; - } - - $this->fail('Expected deleting a passkey for a different owner morph class to fail.'); + $this->assertDatabaseMissing('passkeys', [ + 'id' => $passkey->getKey(), + ]); + Event::assertDispatched( + PasskeyDeleted::class, + static fn (PasskeyDeleted $event): bool => $event->user === $administrator + && $event->passkey->is($passkey), + ); } public function testItDoesNotDispatchPasskeyDeletedEventWithoutListeners(): void @@ -151,8 +118,3 @@ private function createPasskeyForUser(User $user, string $credentialId): Passkey ]); } } - -class AlternatePasskeyUser extends User -{ - protected ?string $table = 'users'; -} diff --git a/tests/Passkeys/PackageMetadataTest.php b/tests/Passkeys/PackageMetadataTest.php index 9abbc5b90d..306802889e 100644 --- a/tests/Passkeys/PackageMetadataTest.php +++ b/tests/Passkeys/PackageMetadataTest.php @@ -52,7 +52,6 @@ public function testDirectDependenciesMatchTheMonorepoConstraints(): void 'paragonie/constant_time_encoding', 'symfony/console', 'symfony/http-foundation', - 'symfony/http-kernel', 'symfony/serializer', 'web-auth/cose-lib', 'web-auth/webauthn-lib', @@ -67,7 +66,6 @@ public function testDirectDependenciesMatchTheMonorepoConstraints(): void 'paragonie/constant_time_encoding', 'symfony/console', 'symfony/http-foundation', - 'symfony/http-kernel', 'symfony/serializer', 'web-auth/cose-lib', 'web-auth/webauthn-lib', From ab5eecec4480dfb43c4be3aadd1ada74d4daf5e4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:12:27 +0000 Subject: [PATCH 13/33] Stop throttling passkey deletion laravel/passkeys-server PR 16 applies the configured passkeys throttle to the login, confirmation and registration routes, while the deletion route uses only the management middleware. Hypervel also throttled deletion, both in standalone Passkeys and in Fortify's passkey routes. Deletion now matches upstream in both packages. It still requires authentication and password confirmation, and the route binding still limits it to the user's own passkeys. The route tests check that the configured throttle reaches registration but not deletion, and the throttle setting's documentation no longer lists deletion. Upstream reference: laravel/passkeys-server main at 4f81dfd5f7. Validation: each changed test file, the Passkeys and Fortify suites, formatting and PHPStan pass. --- src/docs/fortify.md | 2 +- src/fortify/routes/routes.php | 2 +- src/passkeys/routes/routes.php | 2 +- tests/Fortify/FortifyRouteTest.php | 19 ++++--------------- tests/Passkeys/PasskeysRouteTest.php | 16 ++++++++++++---- 5 files changed, 19 insertions(+), 22 deletions(-) diff --git a/src/docs/fortify.md b/src/docs/fortify.md index 331828925e..42237b40b5 100644 --- a/src/docs/fortify.md +++ b/src/docs/fortify.md @@ -777,7 +777,7 @@ Standalone routes use the following configuration options: | `guard` | The guard selected for standalone routes. An omitted or null value uses the current request guard. | | `middleware` | The required middleware applied to every standalone route. | | `management_middleware` | The required additional middleware applied when creating or deleting passkeys. | -| `throttle` | The throttle middleware applied to passkey login, confirmation, registration, and deletion endpoints. Omission uses `throttle:6,1`; null disables throttling. | +| `throttle` | The throttle middleware applied to passkey login, confirmation, and registration endpoints. Omission uses `throttle:6,1`; null disables throttling. | | `redirect` | The successful login destination used when no intended URL exists. Omission uses `/`. | Call `Passkeys::ignoreRoutes()` during boot before registering your own endpoints: diff --git a/src/fortify/routes/routes.php b/src/fortify/routes/routes.php index 3a0d57c16b..af8704bc47 100644 --- a/src/fortify/routes/routes.php +++ b/src/fortify/routes/routes.php @@ -225,7 +225,7 @@ ->name('passkey.store'); Route::delete(RoutePath::for('passkey.destroy', '/user/passkeys/{passkey}'), [PasskeyRegistrationController::class, 'destroy']) - ->middleware($passkeyManageMiddleware) + ->middleware($passkeyMiddleware) ->name('passkey.destroy'); } }); diff --git a/src/passkeys/routes/routes.php b/src/passkeys/routes/routes.php index 651cded03b..23e44b00fd 100644 --- a/src/passkeys/routes/routes.php +++ b/src/passkeys/routes/routes.php @@ -49,7 +49,7 @@ ->name('passkey.store'); Route::delete('/user/passkeys/{passkey}', [PasskeyRegistrationController::class, 'destroy']) - ->middleware($middleware(...$managementMiddleware)) + ->middleware($managementMiddleware) ->name('passkey.destroy'); }); }); diff --git a/tests/Fortify/FortifyRouteTest.php b/tests/Fortify/FortifyRouteTest.php index 810ec7ec03..2398db65b9 100644 --- a/tests/Fortify/FortifyRouteTest.php +++ b/tests/Fortify/FortifyRouteTest.php @@ -36,22 +36,11 @@ public function testFortifyPasskeyRoutesUseFortifyGuardInsteadOfStandalonePasske $this->assertSame('web', config('passkeys.guard')); } - public function testPasskeyDeletionUsesPasswordConfirmationAndOmitsNullLimiter(): void - { - $route = Route::getRoutes()->getByName('passkey.destroy'); - - $this->assertNotNull($route); - - $middleware = $route->gatherMiddleware(); - - $this->assertContains('auth', $middleware); - $this->assertContains('password.confirm', $middleware); - $this->assertStringNotContainsString('throttle:', implode('|', $middleware)); - } - #[DefineEnvironment('withPasskeysLimiter')] - public function testPasskeyDeletionUsesConfiguredThrottle(): void + public function testPasskeyDeletionUsesPasswordConfirmationWithoutThePasskeyLimiter(): void { + $this->assertContains('throttle:passkeys', Route::getRoutes()->getByName('passkey.store')->gatherMiddleware()); + $route = Route::getRoutes()->getByName('passkey.destroy'); $this->assertNotNull($route); @@ -60,7 +49,7 @@ public function testPasskeyDeletionUsesConfiguredThrottle(): void $this->assertContains('auth', $middleware); $this->assertContains('password.confirm', $middleware); - $this->assertContains('throttle:passkeys', $middleware); + $this->assertStringNotContainsString('throttle:', implode('|', $middleware)); } public function testTwoFactorChallengeIsThrottledByDefault(): void diff --git a/tests/Passkeys/PasskeysRouteTest.php b/tests/Passkeys/PasskeysRouteTest.php index bbbb38b0d1..7306c8bac1 100644 --- a/tests/Passkeys/PasskeysRouteTest.php +++ b/tests/Passkeys/PasskeysRouteTest.php @@ -58,7 +58,7 @@ public function testNullGuardConfigDoesNotAddGuardSelectionMiddleware(): void #[WithConfig('passkeys.throttle', null)] public function testNullThrottleOmitsThrottleMiddlewareFromLoginAndManagementRoutes(): void { - foreach (['passkey.login', 'passkey.registration-options', 'passkey.destroy'] as $routeName) { + foreach (['passkey.login', 'passkey.registration-options'] as $routeName) { $route = Route::getRoutes()->getByName($routeName); $this->assertNotNull($route); @@ -78,7 +78,7 @@ public function testOmittedThrottleUsesDefaultMiddlewareOnLoginAndManagementRout require dirname(__DIR__, 2) . '/src/passkeys/routes/routes.php'; - foreach (['passkey.login', 'passkey.registration-options', 'passkey.destroy'] as $routeName) { + foreach (['passkey.login', 'passkey.registration-options'] as $routeName) { $route = Route::getRoutes()->getByName($routeName); $this->assertNotNull($route); @@ -87,11 +87,19 @@ public function testOmittedThrottleUsesDefaultMiddlewareOnLoginAndManagementRout } #[WithConfig('passkeys.throttle', 'throttle:12,1')] - public function testConfiguredThrottleAppliesToPasskeyDeletion(): void + public function testConfiguredThrottleDoesNotApplyToPasskeyDeletion(): void { + $this->assertContains('throttle:12,1', Route::getRoutes()->getByName('passkey.store')->middleware()); + $route = Route::getRoutes()->getByName('passkey.destroy'); $this->assertNotNull($route); - $this->assertContains('throttle:12,1', $route->middleware()); + $this->assertContains('auth', $route->middleware()); + $this->assertContains('password.confirm', $route->middleware()); + $this->assertFalse(array_any( + $route->middleware(), + static fn (mixed $middleware): bool => is_string($middleware) + && str_starts_with($middleware, 'throttle:'), + )); } } From d77ce5bb38820d455b3e5d6fb02b5ca80e3de1e8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:12:47 +0000 Subject: [PATCH 14/33] Include the owner's morph class in passkey user handles laravel/passkeys-server PR 5 derives each WebAuthn user handle from an HMAC of the user's table and primary key. Upstream has one user model, but Hypervel's passkeys have polymorphic owners, and two owner models can share a table and key, such as a model and a subclass that reads the same table. Both then got the same user handle. An authenticator keeps one discoverable credential per relying party and user handle, so registering a passkey for one owner replaced the other owner's passkey on that device. The handle now also includes the owner's morph class. A test checks that two owner models reading the same row get different handles. The documentation explains how the handle is derived, and how applications whose tenant databases share one relying party can add a tenant identifier. Upstream reference: laravel/passkeys-server main at 4f81dfd5f7. Validation: each changed test file, the Passkeys and Fortify suites, formatting and PHPStan pass. --- src/docs/fortify.md | 2 ++ src/passkeys/src/PasskeyAuthenticatable.php | 5 +++-- .../Feature/PasskeyAuthenticatableTest.php | 21 ++++++++++++++++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/docs/fortify.md b/src/docs/fortify.md index 42237b40b5..6b39a0f914 100644 --- a/src/docs/fortify.md +++ b/src/docs/fortify.md @@ -512,6 +512,8 @@ The resolved relying party ID must be a registrable-domain suffix of the resolve `user_handle_secret` is a long-lived, nonempty secret used to derive stable WebAuthn user handles. It defaults to the app key for convenience, but production applications should set a dedicated value before registering passkeys. Changing it changes generated user handles. +Each user handle is derived from the owner's morph type, table, and primary key. If one relying party serves several tenant databases whose owners can share the same type and key, override `getPasskeyUserHandle()` on your passkey user model to include a stable tenant identifier that does not reveal personal information. + ### Frontend Package diff --git a/src/passkeys/src/PasskeyAuthenticatable.php b/src/passkeys/src/PasskeyAuthenticatable.php index 5da86d1ffb..f638ea5707 100644 --- a/src/passkeys/src/PasskeyAuthenticatable.php +++ b/src/passkeys/src/PasskeyAuthenticatable.php @@ -61,13 +61,14 @@ public function hasPasskeysEnabled(): bool /** * Get the unique user handle for WebAuthn. * - * This should be a stable identifier that does not reveal PII. + * This should be a stable identifier that does not reveal PII. The morph + * class keeps polymorphic owners that share a table and key distinct. */ public function getPasskeyUserHandle(): string { return hash_hmac( 'sha256', - $this->getTable() . '|' . $this->getKey(), + $this->getMorphClass() . '|' . $this->getTable() . '|' . $this->getKey(), Passkeys::userHandleSecret(), binary: true, ); diff --git a/tests/Passkeys/Feature/PasskeyAuthenticatableTest.php b/tests/Passkeys/Feature/PasskeyAuthenticatableTest.php index 3f0eb23194..fdf535ee08 100644 --- a/tests/Passkeys/Feature/PasskeyAuthenticatableTest.php +++ b/tests/Passkeys/Feature/PasskeyAuthenticatableTest.php @@ -79,7 +79,7 @@ public function testItReturnsTheSameHandleAcrossFreshModelInstances(): void $handle = $user->getPasskeyUserHandle(); $this->assertSame( - hash_hmac('sha256', 'users|' . $user->getKey(), 'test-secret', binary: true), + hash_hmac('sha256', User::class . '|users|' . $user->getKey(), 'test-secret', binary: true), $handle, ); $this->assertNotSame((string) $user->getKey(), $handle); @@ -146,6 +146,20 @@ public function testItIncludesTheModelTableWhenDerivingUserHandles(): void $this->assertNotSame($user->getPasskeyUserHandle(), $admin->getPasskeyUserHandle()); } + public function testItIncludesTheOwnerMorphClassWhenDerivingUserHandles(): void + { + config(['passkeys.user_handle_secret' => 'test-secret']); + + $user = User::create([ + 'name' => 'Alex Müller', + 'email' => 'alex@example.com', + ]); + + $sameRowOwner = SameTableUser::findOrFail($user->getKey()); + + $this->assertNotSame($user->getPasskeyUserHandle(), $sameRowOwner->getPasskeyUserHandle()); + } + public function testDeletingOwnerDeletesRelatedPasskeys(): void { $user = User::create([ @@ -291,6 +305,11 @@ class AdminUser extends Authenticatable implements PasskeyUser protected array $guarded = []; } +class SameTableUser extends User +{ + protected ?string $table = 'users'; +} + class SoftDeletingPasskeyUser extends Authenticatable implements PasskeyUser { use PasskeyAuthenticatable; From cc7f84e5d08bca91b6f1f5dd2e21f0b00eb184cb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:13:00 +0000 Subject: [PATCH 15/33] Correct the Passkeys differences from Laravel The Passkeys README listed two entries that aren't lasting differences from laravel/passkeys-server. Upstream now uses web-auth/webauthn-lib's CredentialRecord API too. The note about registration responses described an internal fix rather than a public contract difference. Both are removed. It was also missing two real differences. Upstream exposes a public, directly writable static $passkeyModel property; Hypervel keeps it private so the worker-lifetime value stays typed and resettable, and applications use usePasskeyModel() instead. Upstream's protected StorePasskey::ensureCredentialIsUnique() runs a query before inserting each passkey. Hypervel omits it because the unique credential_id index already rejects duplicates, and createPasskey() turns that violation into the same error. The README now records both, and a source comment marks where the omitted method would sit. Upstream reference: laravel/passkeys-server main at 4f81dfd5f7. Validation: formatting passes. --- src/passkeys/README.md | 4 ++-- src/passkeys/src/Actions/StorePasskey.php | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/passkeys/README.md b/src/passkeys/README.md index 9b5beda169..093475869a 100644 --- a/src/passkeys/README.md +++ b/src/passkeys/README.md @@ -7,10 +7,10 @@ Documentation: https://hypervel.org/docs/fortify - Passkeys use a polymorphic `user` owner relation so multiple authenticatable model classes can share the same passkeys table. - Standalone Passkeys follows Hypervel's current default guard selected by `Auth::shouldUse()` or `auth.defaults.guard`, with optional `passkeys.guard` route-group selection for built-in standalone routes. -- Passkeys use current non-deprecated `web-auth/webauthn-lib` APIs, including `CredentialRecord`. - Passkeys omit Laravel's `relyingPartyName()` because `web-auth/webauthn-lib` deprecates non-empty relying party names. - Passkeys include explicit orphan cleanup for polymorphic owners. - Passkeys support boot-time request-aware callbacks for redirects and WebAuthn relying party / origin settings, such as for custom domains, multi-guard apps, or multi-tenant apps. -- Passkey registration responses do not store request data on singleton response instances. +- Passkeys keep Laravel's directly writable `$passkeyModel` static property private so worker-lifetime state remains typed and resettable. Use `usePasskeyModel()` instead. +- Passkeys omit Laravel's protected `StorePasskey::ensureCredentialIsUnique()` pre-check because the unique `credential_id` index rejects duplicates without an extra query. Override `createPasskey()` to customize duplicate handling. Ported from: https://github.com/laravel/passkeys-server diff --git a/src/passkeys/src/Actions/StorePasskey.php b/src/passkeys/src/Actions/StorePasskey.php index 40055f837c..2952da3a34 100644 --- a/src/passkeys/src/Actions/StorePasskey.php +++ b/src/passkeys/src/Actions/StorePasskey.php @@ -99,6 +99,9 @@ protected function hostFromOptions(PublicKeyCredentialCreationOptions $options): return $options->rp->id; } + // Intentionally omitted: ensureCredentialIsUnique(). The unique credential_id + // index rejects duplicates in createPasskey() without an extra query. + /** * Create the passkey record for the user. * From 9ac4124fc9b35b192f7b0772846e9c3f8c9037e4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:14:55 +0000 Subject: [PATCH 16/33] Record the owner-scoped passkey route binding laravel/passkeys-server PR 14 binds {passkey} to any passkey of the configured model, and its destroy action returns 403 for another user's passkey. Hypervel scopes the binding to the authenticated owner, so another user's passkey returns 404, and application routes that use a {passkey} parameter get the same scope. The Fortify documentation already describes this. The Passkeys README now lists it as a difference from Laravel. Upstream reference: laravel/passkeys-server main at 4f81dfd5f7. --- src/passkeys/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/src/passkeys/README.md b/src/passkeys/README.md index 093475869a..fa9e556bc4 100644 --- a/src/passkeys/README.md +++ b/src/passkeys/README.md @@ -7,6 +7,7 @@ Documentation: https://hypervel.org/docs/fortify - Passkeys use a polymorphic `user` owner relation so multiple authenticatable model classes can share the same passkeys table. - Standalone Passkeys follows Hypervel's current default guard selected by `Auth::shouldUse()` or `auth.defaults.guard`, with optional `passkeys.guard` route-group selection for built-in standalone routes. +- The `{passkey}` route binding resolves only the authenticated owner's passkeys, so another user's passkey returns 404 instead of Laravel's 403. Routes that manage other users' passkeys should use a different parameter name. - Passkeys omit Laravel's `relyingPartyName()` because `web-auth/webauthn-lib` deprecates non-empty relying party names. - Passkeys include explicit orphan cleanup for polymorphic owners. - Passkeys support boot-time request-aware callbacks for redirects and WebAuthn relying party / origin settings, such as for custom domains, multi-guard apps, or multi-tenant apps. From e502e6513d04274a0e21994a4aaf9697ff112cfc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:19:09 +0000 Subject: [PATCH 17/33] Record the Passkeys sync checkpoint laravel/passkeys-server is reviewed through 4f81dfd5f7f983bdfe3ee6b3971c51acaa7844c3, up to PR 40. All upstream changes since the initial template commit are either ported, already present, or don't apply to Hypervel. --- docs/upstream-sync/sync.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml index 8c2de00dbe..740f625486 100644 --- a/docs/upstream-sync/sync.yaml +++ b/docs/upstream-sync/sync.yaml @@ -60,8 +60,8 @@ laravel/fortify: laravel/passkeys-server: branch: main - checked_through: 75e7c69373123c60f34a03ec2e13438e5d2ee360 - last_reviewed_pr: null + checked_through: 4f81dfd5f7f983bdfe3ee6b3971c51acaa7844c3 + last_reviewed_pr: 40 sync_date: '2026-10-02' notes: Composer package is laravel/passkeys. From 79063206107a9ab0b031cf87a991e047b5e382a3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:22:23 +0000 Subject: [PATCH 18/33] Report resolved queue names in Horizon migration events Horizon's RedisQueue fires JobsMigrated with the destination key that the base queue passes to migrateExpiredJobs(). On Redis Cluster, pop() builds that key through getQueueRedisKey(), which adds a hash tag, so the event reported "{critical}" while every other Horizon event reported "critical". A getQueueRedisKey() override, such as a tenant prefix, leaked into the name in the same way. laravel/horizon and the Laravel framework queue share this defect. Horizon's pop() now records the resolved queue name in coroutine context while the base pop() runs, and migration events use it. The context is cleared in a finally block, so failed pops do not leak the name, and direct migrateExpiredJobs() calls still report their own destination. The base queue keeps Laravel's storage path, including the protected getQueueRedisKey() hook, and JobReserved reuses the resolved name. The tests cover plain and hash-tagged names on Cluster, concurrent pops that yield during Redis calls, cleanup after a failed pop, direct migration calls, and the queue name in the real Horizon migration test. Upstream reference: laravel/horizon 5.x at 26fef6f and laravel/framework master at 588c1c948c. Validation: each changed test file, the Horizon and Queue suites, the Horizon and Queue integration suites against Redis, formatting and PHPStan pass. --- src/horizon/src/RedisQueue.php | 20 +++- tests/Horizon/Unit/RedisQueueTest.php | 112 ++++++++++++++++++ .../Horizon/Feature/QueueProcessingTest.php | 5 +- 3 files changed, 133 insertions(+), 4 deletions(-) diff --git a/src/horizon/src/RedisQueue.php b/src/horizon/src/RedisQueue.php index c24d12cdfb..e0d27c008c 100644 --- a/src/horizon/src/RedisQueue.php +++ b/src/horizon/src/RedisQueue.php @@ -26,6 +26,8 @@ class RedisQueue extends BaseQueue { public const string LAST_PUSHED_CONTEXT_KEY = '__horizon.queue.last_pushed'; + protected const string POPPING_QUEUE_CONTEXT_KEY = '__horizon.queue.popping'; + /** * Get the number of queue jobs that are ready to process. */ @@ -161,7 +163,17 @@ protected function handlePayloadPushedInBulk(string $payload, ?string $queue): v #[Override] public function pop(UnitEnum|string|null $queue = null, int $index = 0): ?Job { - return tap(parent::pop($queue, $index), function ($result) use ($queue) { + $name = $this->getQueue($queue); + + CoroutineContext::set(static::POPPING_QUEUE_CONTEXT_KEY, $name); + + try { + $result = parent::pop($queue, $index); + } finally { + CoroutineContext::forget(static::POPPING_QUEUE_CONTEXT_KEY); + } + + return tap($result, function ($result) use ($name) { /** @var null|RedisJob $result */ if ($result && $this->hasEventListeners(JobReserved::class)) { try { @@ -170,7 +182,7 @@ public function pop(UnitEnum|string|null $queue = null, int $index = 0): ?Job return; } - $this->event($this->getQueue($queue), $event); + $this->event($name, $event); } }); } @@ -186,7 +198,9 @@ public function migrateExpiredJobs(string $from, string $to): array { return tap(parent::migrateExpiredJobs($from, $to), function ($jobs) use ($to) { if ($this->hasEventListeners(JobsMigrated::class)) { - $this->event($to, new JobsMigrated($jobs)); + // Pop migrates through storage keys, which can carry a Cluster hash tag or a + // getQueueRedisKey() override, so its events report the resolved queue name. + $this->event(CoroutineContext::get(static::POPPING_QUEUE_CONTEXT_KEY, $to), new JobsMigrated($jobs)); } }); } diff --git a/tests/Horizon/Unit/RedisQueueTest.php b/tests/Horizon/Unit/RedisQueueTest.php index 84f1bbc1c0..ac47cfddf2 100644 --- a/tests/Horizon/Unit/RedisQueueTest.php +++ b/tests/Horizon/Unit/RedisQueueTest.php @@ -4,12 +4,22 @@ namespace Hypervel\Tests\Horizon\Unit; +use Hypervel\Container\Container; +use Hypervel\Contracts\Events\Dispatcher as DispatcherContract; +use Hypervel\Contracts\Queue\Job; use Hypervel\Contracts\Redis\Factory; +use Hypervel\Events\Dispatcher; +use Hypervel\Horizon\Events\JobsMigrated; use Hypervel\Horizon\RedisQueue; +use Hypervel\Queue\LuaScripts; use Hypervel\Redis\RedisProxy; use Hypervel\Tests\Horizon\UnitTestCase; use Hypervel\Tests\Queue\Fixtures\IntegerQueueName; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; +use RedisException; + +use function Hypervel\Coroutine\parallel; class RedisQueueTest extends UnitTestCase { @@ -28,4 +38,106 @@ public function testReadyNowReadsTheClusterSafeQueueKey(): void $this->assertSame(3, $queue->readyNow('critical')); $this->assertSame(4, $queue->readyNow(IntegerQueueName::Zero)); } + + #[TestWith(['critical'])] + #[TestWith(['{critical}'])] + public function testMigrationEventsReportTheQueueNameOnCluster(string $name): void + { + $key = 'queues:{critical}'; + $connection = m::mock(RedisProxy::class); + $connection->shouldReceive('isCluster')->andReturnTrue(); + $connection->shouldReceive('eval') + ->twice() + ->with(LuaScripts::migrateExpiredJobs(), 3, $key . ':delayed', $key, $key . ':notify', m::type('int'), m::type('int')) + ->andReturn([]); + $connection->shouldReceive('eval') + ->once() + ->with(LuaScripts::migrateExpiredJobs(), 3, $key . ':reserved', $key, $key . ':notify', m::type('int'), m::type('int')) + ->andReturn([]); + $connection->shouldReceive('eval') + ->once() + ->with(LuaScripts::pop(), 3, $key, $key . ':reserved', $key . ':notify', m::type('int')) + ->andReturn([]); + + $queues = []; + $queue = $this->queueRecordingMigrations($connection, $queues); + + $this->assertNull($queue->pop($name)); + + $queue->migrateExpiredJobs($key . ':delayed', $key); + + $this->assertSame([$name, $name, '{critical}'], $queues); + } + + public function testConcurrentPopsReportTheirOwnQueueNames(): void + { + $connection = m::mock(RedisProxy::class); + $connection->shouldReceive('isCluster')->andReturnTrue(); + $connection->shouldReceive('eval')->andReturnUsing(function (): array { + usleep(5000); + + return []; + }); + + $queues = []; + $queue = $this->queueRecordingMigrations($connection, $queues); + + parallel([ + fn (): ?Job => $queue->pop('critical'), + fn (): ?Job => $queue->pop('low'), + ]); + + $this->assertEqualsCanonicalizing(['critical', 'critical', 'low', 'low'], $queues); + } + + public function testFailedPopDoesNotLeakItsQueueNameIntoLaterMigrations(): void + { + $exception = new RedisException('Connection lost'); + $connection = m::mock(RedisProxy::class); + $connection->shouldReceive('isCluster')->andReturnFalse(); + $connection->shouldReceive('eval') + ->once() + ->with(LuaScripts::migrateExpiredJobs(), 3, 'queues:critical:delayed', 'queues:critical', 'queues:critical:notify', m::type('int'), m::type('int')) + ->andThrow($exception); + $connection->shouldReceive('eval') + ->once() + ->with(LuaScripts::migrateExpiredJobs(), 3, 'queues:low:delayed', 'queues:low', 'queues:low:notify', m::type('int'), m::type('int')) + ->andReturn([]); + + $queues = []; + $queue = $this->queueRecordingMigrations($connection, $queues); + + try { + $queue->pop('critical'); + $this->fail('The pop should have failed.'); + } catch (RedisException $caught) { + $this->assertSame($exception, $caught); + } + + $queue->migrateExpiredJobs('queues:low:delayed', 'queues:low'); + + $this->assertSame(['low'], $queues); + } + + /** + * Create a Horizon queue that records the queue name of each migration event. + */ + private function queueRecordingMigrations(RedisProxy $connection, array &$queues): RedisQueue + { + $redis = m::mock(Factory::class); + $redis->shouldReceive('connection')->with('default')->andReturn($connection); + + $container = new Container; + $events = new Dispatcher($container); + $container->instance(DispatcherContract::class, $events); + $events->listen(JobsMigrated::class, function (JobsMigrated $event) use (&$queues): void { + $queues[] = $event->queue; + }); + + $queue = new RedisQueue($redis, 'default', 'default'); + $queue->setContainer($container); + $queue->setConnectionName('redis'); + + return $queue; + } } diff --git a/tests/Integration/Horizon/Feature/QueueProcessingTest.php b/tests/Integration/Horizon/Feature/QueueProcessingTest.php index cb39c93aed..d8bc70c6d1 100644 --- a/tests/Integration/Horizon/Feature/QueueProcessingTest.php +++ b/tests/Integration/Horizon/Feature/QueueProcessingTest.php @@ -291,13 +291,16 @@ public function testStaleReservedJobsAreMarkedAsPendingAfterMigrating(): void Redis::connection('horizon')->hset($id, 'status', 'reserved'); $status = null; - Event::listen(JobsMigrated::class, function (JobsMigrated $event) use ($id, &$status): void { + $queue = null; + Event::listen(JobsMigrated::class, function (JobsMigrated $event) use ($id, &$status, &$queue): void { $status = Redis::connection('horizon')->hget($id, 'status'); + $queue = $event->queue; }); $this->work(); $this->assertSame('pending', $status); + $this->assertSame('default', $queue); } public function testInvalidRawPayloadIsTerminallyRemovedWithoutHorizonTelemetryFailure(): void From d263851d51f68a06270ea05efc3aa766f7a21229 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:22:39 +0000 Subject: [PATCH 19/33] Restore protected console kernel hooks Laravel's console kernel declares schedule(), commands() and load() as protected methods that application kernels override. Hypervel made them public and added them to the Kernel contract, so an application kernel ported from Laravel with protected overrides failed with a fatal visibility error. Nothing in the framework called them from outside the kernel. The three methods are protected again and no longer part of the contract, and the Artisan facade's generated docblock drops them. resolveConsoleSchedule() and addCommandPaths() remain the public entry points. A test kernel overrides all three hooks as protected methods and confirms that bootstrapping and schedule resolution still reach them. Upstream reference: laravel/framework master at 588c1c948c. Validation: the kernel test, FacadeDocblocksTest, the Foundation, Console and Testbench suites, formatting and PHPStan pass. --- src/contracts/src/Console/Kernel.php | 15 ---------- src/foundation/src/Console/Kernel.php | 6 ++-- src/support/src/Facades/Artisan.php | 3 -- tests/Foundation/Console/KernelTest.php | 39 +++++++++++++++++++++++++ 4 files changed, 42 insertions(+), 21 deletions(-) diff --git a/src/contracts/src/Console/Kernel.php b/src/contracts/src/Console/Kernel.php index 7edad6d989..af641bddf6 100644 --- a/src/contracts/src/Console/Kernel.php +++ b/src/contracts/src/Console/Kernel.php @@ -25,31 +25,16 @@ public function handle(InputInterface $input, ?OutputInterface $output = null): */ public function bootstrap(): void; - /** - * Define the application's command schedule. - */ - public function schedule(Schedule $schedule): void; - /** * Resolve a console schedule instance. */ public function resolveConsoleSchedule(): Schedule; - /** - * Register the commands for the application. - */ - public function commands(): void; - /** * Register a Closure based command with the application. */ public function command(string $signature, Closure $callback): ClosureCommand; - /** - * Add loadPaths in the given directory. - */ - public function load(array|string $paths): void; - /** * Set the Artisan commands provided by the application. * diff --git a/src/foundation/src/Console/Kernel.php b/src/foundation/src/Console/Kernel.php index a6832aebb1..fe57f774d5 100644 --- a/src/foundation/src/Console/Kernel.php +++ b/src/foundation/src/Console/Kernel.php @@ -294,7 +294,7 @@ public function commandStartedAt(): ?CarbonImmutable /** * Define the application's command schedule. */ - public function schedule(Schedule $schedule): void + protected function schedule(Schedule $schedule): void { } @@ -329,7 +329,7 @@ protected function scheduleCache(): ?string /** * Register the commands for the application. */ - public function commands(): void + protected function commands(): void { } @@ -352,7 +352,7 @@ public function command(string $signature, Closure $callback): ClosureCommand /** * Register all of the commands in the given directory. */ - public function load(array|string $paths): void + protected function load(array|string $paths): void { $paths = array_unique(Arr::wrap($paths)); diff --git a/src/support/src/Facades/Artisan.php b/src/support/src/Facades/Artisan.php index 3f00ee5253..ebd374b52c 100644 --- a/src/support/src/Facades/Artisan.php +++ b/src/support/src/Facades/Artisan.php @@ -15,18 +15,15 @@ * @method static void bootstrapWithoutBootingProviders() * @method static int call(string $command, array $parameters = [], \Symfony\Component\Console\Output\OutputInterface|null $outputBuffer = null) * @method static \Hypervel\Foundation\Console\ClosureCommand command(string $signature, \Closure $callback) - * @method static void commands() * @method static \Hypervel\Support\CarbonImmutable|null commandStartedAt() * @method static \Symfony\Component\Console\Command\Command|null findCommand(string $name) * @method static \Hypervel\Contracts\Console\Application getArtisan() * @method static int handle(\Symfony\Component\Console\Input\InputInterface $input, \Symfony\Component\Console\Output\OutputInterface|null $output = null) - * @method static void load(array|string $paths) * @method static string output() * @method static \Hypervel\Foundation\Bus\PendingDispatch queue(string $command, array $parameters = []) * @method static void registerCommand(\Symfony\Component\Console\Command\Command $command) * @method static \Hypervel\Console\Scheduling\Schedule resolveConsoleSchedule() * @method static int run(\Symfony\Component\Console\Input\InputInterface|null $input = null, \Symfony\Component\Console\Output\OutputInterface|null $output = null) - * @method static void schedule(\Hypervel\Console\Scheduling\Schedule $schedule) * @method static void setArtisan(\Hypervel\Contracts\Console\Application|null $artisan) * @method static void terminate(\Symfony\Component\Console\Input\InputInterface $input, int $status) * @method static void whenCommandLifecycleIsLongerThan(\Carbon\CarbonInterval|\DateTimeInterface|int|float $threshold, callable $handler) diff --git a/tests/Foundation/Console/KernelTest.php b/tests/Foundation/Console/KernelTest.php index 02bbcde614..450029713a 100644 --- a/tests/Foundation/Console/KernelTest.php +++ b/tests/Foundation/Console/KernelTest.php @@ -9,6 +9,7 @@ use Hypervel\Console\Command; use Hypervel\Console\Scheduling\CacheEventMutex; use Hypervel\Console\Scheduling\CacheSchedulingMutex; +use Hypervel\Console\Scheduling\Schedule; use Hypervel\Contracts\Console\Application as ConsoleApplicationContract; use Hypervel\Contracts\Console\Kernel as KernelContract; use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; @@ -263,6 +264,44 @@ public function testCommand(): void } } + public function testApplicationKernelsCanOverrideTheProtectedConsoleHooks(): void + { + $kernel = new class($this->app, $this->app->make('events')) extends Kernel { + public array $calls = []; + + /** + * Record that the schedule was defined. + */ + protected function schedule(Schedule $schedule): void + { + $this->calls[] = 'schedule'; + } + + /** + * Load the fixture commands directory. + */ + protected function commands(): void + { + $this->load(__DIR__ . '/Commands'); + } + + /** + * Record the loaded paths before registering their commands. + */ + protected function load(array|string $paths): void + { + $this->calls[] = $paths; + + parent::load($paths); + } + }; + + $kernel->bootstrap(); + $kernel->resolveConsoleSchedule(); + + $this->assertSame([__DIR__ . '/Commands', 'schedule'], $kernel->calls); + } + public function testSetArtisanSynchronizesTheKernelAndContainerBeforeReboundCallbacks(): void { $kernel = $this->app->make(KernelContract::class); From ed3ed483b84f75dc1821aae72405d9b885ad190a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:22:52 +0000 Subject: [PATCH 20/33] Support placeholder casing in five more validation messages Validation messages support :Other, :OTHER and similar casing variants of their placeholders through replaceWhileKeepingCase(). The missing_unless and present_unless replacements, and the :values replacement shared by mimes, mimetypes and extensions, still replaced only the lowercase form, so custom messages using the capitalized or uppercase variants kept the raw placeholder. Laravel has the same gap. missing_unless and present_unless now use replaceWhileKeepingCase(). mimes adds the :VALUES and :Values variants, capitalizing each item as replaceIn() does, and mimetypes and extensions delegate to it. MIME types and extensions stay literal: they do not go through replaceIn(), which would apply custom display values to them. The existing casing tests gain rows for all five rules. Upstream reference: laravel/framework master at 588c1c948c. Validation: the validator test file, the Validation suite, formatting and PHPStan pass. --- .../src/Concerns/ReplacesAttributes.php | 23 +++++++++++++------ tests/Validation/ValidationValidatorTest.php | 5 ++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/validation/src/Concerns/ReplacesAttributes.php b/src/validation/src/Concerns/ReplacesAttributes.php index c49f2d7432..0e6e274e4d 100644 --- a/src/validation/src/Concerns/ReplacesAttributes.php +++ b/src/validation/src/Concerns/ReplacesAttributes.php @@ -116,7 +116,7 @@ protected function replaceEncoding(string $message, string $attribute, string $r */ protected function replaceExtensions(string $message, string $attribute, string $rule, array $parameters): string { - return str_replace(':values', implode(', ', $parameters), $message); + return $this->replaceMimes($message, $attribute, $rule, $parameters); } /** @@ -176,10 +176,10 @@ protected function replaceMissingIf(string $message, string $attribute, string $ */ protected function replaceMissingUnless(string $message, string $attribute, string $rule, array $parameters): string { - return str_replace([':other', ':value'], [ - $this->getDisplayableAttribute($parameters[0]), - $this->getDisplayableValue($parameters[0], $parameters[1]), - ], $message); + return $this->replaceWhileKeepingCase($message, [ + 'other' => $this->getDisplayableAttribute($parameters[0]), + 'value' => $this->getDisplayableValue($parameters[0], $parameters[1]), + ]); } /** @@ -314,7 +314,7 @@ protected function replaceRequiredArrayKeys(string $message, string $attribute, */ protected function replaceMimetypes(string $message, string $attribute, string $rule, array $parameters): string { - return str_replace(':values', implode(', ', $parameters), $message); + return $this->replaceMimes($message, $attribute, $rule, $parameters); } /** @@ -324,7 +324,16 @@ protected function replaceMimetypes(string $message, string $attribute, string $ */ protected function replaceMimes(string $message, string $attribute, string $rule, array $parameters): string { - return str_replace(':values', implode(', ', $parameters), $message); + // MIME types and extensions are shown literally, without custom display values. + return str_replace( + [':values', ':VALUES', ':Values'], + [ + implode(', ', $parameters), + Str::upper(implode(', ', $parameters)), + implode(', ', array_map(Str::ucfirst(...), $parameters)), + ], + $message, + ); } /** diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index 5fd1983a32..885333525a 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -949,7 +949,9 @@ public function testCapitalizedDisplayableValuesAreReplaced() #[TestWith(['declined_if', ['foo' => 'yes', 'bar' => 'aAa']])] #[TestWith(['missing_if', ['foo' => 'yes', 'bar' => 'aAa']])] + #[TestWith(['missing_unless', ['foo' => 'yes', 'bar' => 'bBb']])] #[TestWith(['present_if', ['bar' => 'aAa']])] + #[TestWith(['present_unless', ['bar' => 'bBb']])] #[TestWith(['required_if', ['bar' => 'aAa']])] public function testConditionalRulePlaceholdersPreserveCasingVariants(string $rule, array $data): void { @@ -1000,6 +1002,9 @@ public function testProhibitedUnlessPlaceholdersPreserveCasingVariants(): void #[TestWith(['ends_with', 'other'])] #[TestWith(['doesnt_end_with', 'tAylor'])] #[TestWith(['doesnt_start_with', 'sVen'])] + #[TestWith(['extensions', 'file'])] + #[TestWith(['mimes', 'file'])] + #[TestWith(['mimetypes', 'file'])] public function testValueListRulePlaceholdersPreserveCasingVariants(string $rule, array|string $value): void { $validator = new Validator( From cf94efcbf86ae04ad347b9befe8c340ff07d586f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:23:01 +0000 Subject: [PATCH 21/33] Compile null-safe column comparisons for every database laravel/framework PR 58962 compiles where('a', '<=>', $value) through each grammar's null-safe equality, but whereColumn('a', '<=>', 'b') still emitted MySQL's <=> operator on every driver, which SQLite and PostgreSQL reject. Laravel has the same gap. The base grammar's whereColumn() now routes <=> through whereNullSafeEquals() with the second column as a wrapped expression, the same way whereSub() reuses value comparisons. MySQL keeps <=>, SQLite compiles to "is", and PostgreSQL to "is not distinct from". Column, OR and join comparisons share this path. The query builder tests cover the three grammars, including orWhereColumn() and a join condition, and the query builder docs show the operator under whereColumn. Upstream reference: laravel/framework master at 588c1c948c, including PR 58962. Validation: the query builder test file, the Database suite, the SQLite integration suite, formatting and PHPStan pass. --- src/database/src/Query/Grammars/Grammar.php | 7 ++++++ src/docs/queries.md | 8 +++++++ tests/Database/DatabaseQueryBuilderTest.php | 26 +++++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/src/database/src/Query/Grammars/Grammar.php b/src/database/src/Query/Grammars/Grammar.php index d18f205888..7ec56bed49 100755 --- a/src/database/src/Query/Grammars/Grammar.php +++ b/src/database/src/Query/Grammars/Grammar.php @@ -501,6 +501,13 @@ protected function dateBasedWhere(string $type, Builder $query, array $where): s */ protected function whereColumn(Builder $query, array $where): string { + if ($where['operator'] === '<=>') { + $where['column'] = $where['first']; + $where['value'] = new QueryExpression($this->wrap($where['second'])); + + return $this->whereNullSafeEquals($query, $where); + } + $operator = str_replace('?', '??', $where['operator']); return $this->wrap($where['first']) . ' ' . $operator . ' ' . $this->wrap($where['second']); diff --git a/src/docs/queries.md b/src/docs/queries.md index e4f290d9fd..a766a02b6a 100644 --- a/src/docs/queries.md +++ b/src/docs/queries.md @@ -1206,6 +1206,14 @@ $users = DB::table('users') ->get(); ``` +The `<=>` operator compares two columns while treating two `NULL` values as equal, on every supported database: + +```php +$users = DB::table('users') + ->whereColumn('billing_email', '<=>', 'email') + ->get(); +``` + You may also pass an array of column comparisons to the `whereColumn` method. These conditions will be joined using the `and` operator: ```php diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 1640150d74..fed55d2945 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -1431,6 +1431,32 @@ public function testWhereNullSafeEqualsWithSubqueryPostgres(): void $this->assertSame([1, 'bar'], $builder->getBindings()); } + public function testWhereColumnNullSafeEqualsMySql(): void + { + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->whereColumn('first_name', '<=>', 'last_name'); + $this->assertSame('select * from `users` where `first_name` <=> `last_name`', $builder->toSql()); + } + + public function testWhereColumnNullSafeEqualsSQLite(): void + { + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->whereColumn('first_name', '<=>', 'last_name'); + $this->assertSame('select * from "users" where "first_name" is "last_name"', $builder->toSql()); + } + + public function testWhereColumnNullSafeEqualsPostgres(): void + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('id', 1)->orWhereColumn('updated_at', '<=>', 'created_at'); + $this->assertSame('select * from "users" where "id" = ? or "updated_at" is not distinct from "created_at"', $builder->toSql()); + $this->assertSame([1], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->join('contacts', 'users.email', '<=>', 'contacts.email'); + $this->assertSame('select * from "users" inner join "contacts" on "users"."email" is not distinct from "contacts"."email"', $builder->toSql()); + } + public function testWhereBetweens(): void { $builder = $this->getBuilder(); From 82a6476eecbb4c0272c4c570454a77e4250b73d5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:23:36 +0000 Subject: [PATCH 22/33] Complete has-through generic annotations and remove their suppressions HasOneOrMany and MorphOneOrMany take three template types, but PendingHasThroughRelationship's TLocalRelationship bound and has() callback type, and the string branch of through(), supplied only two. Laravel has the same annotations. phpstan.neon.dist hid the resulting errors by ignoring generics.lessTypes and generics.notSubtype across src/database, which would also hide genuine generic arity mistakes. The annotations now give the missing TResult as *, the string branch of through() returns a complete PendingHasThroughRelationship type, and the two database-wide ignores are removed. Full analysis passes without them. The type fixtures cover the corrected string branch and a through()->has() callback whose relation type PHPStan infers from context. Upstream reference: laravel/framework master at 588c1c948c. Validation: composer analyse, including the type fixtures, passes. --- phpstan.neon.dist | 6 ------ src/database/src/Eloquent/Concerns/HasRelationships.php | 2 +- .../src/Eloquent/PendingHasThroughRelationship.php | 4 ++-- types/Database/Eloquent/Relations.php | 7 ++++++- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index efdf27f77d..6ef9643941 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -68,12 +68,6 @@ parameters: - '#Call to an undefined method TModel of Hypervel\\Database\\Eloquent\\Model::#' - '#Call to an undefined method TRelatedModel of Hypervel\\Database\\Eloquent\\Model::#' - # Deep generic template limitations - PHPStan can't fully resolve complex generic type relationships - - identifier: generics.lessTypes - path: src/database/* - - identifier: generics.notSubtype - path: src/database/* - # Covariant template types in invariant/contravariant positions - Laravel uses @template-covariant # for semantic documentation on collection-like classes even though it violates strict variance rules. # The code is functionally correct; this is a PHPStan limitation with PHP's lack of runtime generics. diff --git a/src/database/src/Eloquent/Concerns/HasRelationships.php b/src/database/src/Eloquent/Concerns/HasRelationships.php index aa9a62558b..edaf2e25b3 100644 --- a/src/database/src/Eloquent/Concerns/HasRelationships.php +++ b/src/database/src/Eloquent/Concerns/HasRelationships.php @@ -465,7 +465,7 @@ protected function guessBelongsToRelation(): string * @param HasMany|HasOne|string $relationship * @return ( * $relationship is string - * ? PendingHasThroughRelationship + * ? PendingHasThroughRelationship> * : ( * $relationship is HasMany * ? PendingHasThroughRelationship> diff --git a/src/database/src/Eloquent/PendingHasThroughRelationship.php b/src/database/src/Eloquent/PendingHasThroughRelationship.php index fddb19dc6a..3985ba4eb9 100644 --- a/src/database/src/Eloquent/PendingHasThroughRelationship.php +++ b/src/database/src/Eloquent/PendingHasThroughRelationship.php @@ -17,7 +17,7 @@ /** * @template TIntermediateModel of Model * @template TDeclaringModel of Model - * @template TLocalRelationship of HasOneOrMany + * @template TLocalRelationship of HasOneOrMany */ class PendingHasThroughRelationship { @@ -52,7 +52,7 @@ public function __construct(Model $rootModel, HasOneOrMany $localRelationship) * * @template TRelatedModel of Model * - * @param (callable(TIntermediateModel): (HasMany|HasOne|MorphOneOrMany))|string $callback + * @param (callable(TIntermediateModel): (HasMany|HasOne|MorphOneOrMany))|string $callback * @return ( * $callback is string * ? HasManyThrough|HasOneThrough diff --git a/types/Database/Eloquent/Relations.php b/types/Database/Eloquent/Relations.php index 33cc82a098..9c19c21e4c 100644 --- a/types/Database/Eloquent/Relations.php +++ b/types/Database/Eloquent/Relations.php @@ -181,6 +181,11 @@ public function posts(): HasMany $hasMany = $this->hasMany(Post::class); assertType('Hypervel\Database\Eloquent\Relations\HasMany', $hasMany); + assertType( + 'Hypervel\Database\Eloquent\Relations\HasManyThrough', + $this->through($hasMany)->has(fn ($post) => $post->comments()), + ); + return $hasMany; } @@ -234,7 +239,7 @@ public function car(): HasOneThrough $through = $this->through('mechanic'); assertType( - 'Hypervel\Database\Eloquent\PendingHasThroughRelationship', + 'Hypervel\Database\Eloquent\PendingHasThroughRelationship>', $through, ); assertType( From 43ba469604ca4d37fa56983924b0a05a22e71415 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:23:46 +0000 Subject: [PATCH 23/33] Require a stable Sentry SDK release The root and split Sentry manifests required sentry/sentry dev-master, because Hypervel's coroutine runtime context storage needed the SDK's RuntimeContext API before it was released. Fresh installs could resolve any development commit, including a broken one. Sentry 4.32.0 ships RuntimeContext and RuntimeContextStorageInterface, so both manifests now require ^4.32. psy/psysh stays on dev-main: its latest release, v0.12.24, predates the change Hypervel needs. Validation: composer resolves sentry/sentry 4.32.0, and the Sentry suite and PHPStan pass. --- composer.json | 2 +- src/sentry/composer.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index bd4d243c3b..b7301c30b3 100644 --- a/composer.json +++ b/composer.json @@ -87,7 +87,7 @@ "psr/log": "^3.0", "psr/simple-cache": "^3.0", "psy/psysh": "dev-main", - "sentry/sentry": "dev-master", + "sentry/sentry": "^4.32", "spomky-labs/otphp": "^11.0", "symfony/console": "^8.1.2", "symfony/dom-crawler": "^8.1", diff --git a/src/sentry/composer.json b/src/sentry/composer.json index 2538cd2030..fe42294c46 100644 --- a/src/sentry/composer.json +++ b/src/sentry/composer.json @@ -59,7 +59,7 @@ "nyholm/psr7": "^1.0", "psr/http-message": "^2.0", "psr/log": "^3.0", - "sentry/sentry": "dev-master", + "sentry/sentry": "^4.32", "symfony/console": "^8.1.2", "symfony/http-foundation": "^8.1", "symfony/psr-http-message-bridge": "^8.1" From 91cd2798d436fdc67aa3fd6758aa8813e211aad3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:24:15 +0000 Subject: [PATCH 24/33] Document float request input retrieval Requests share float() with the other typed input helpers, but the request docs described only string, integer and the clamped variants. Laravel's request docs have the same omission. A short section after the integer helper now covers float() and its default. Upstream reference: laravel/docs 13.x at 9c2295fc4b. --- src/docs/requests.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/docs/requests.md b/src/docs/requests.md index 010c0bce5d..5019def73b 100644 --- a/src/docs/requests.md +++ b/src/docs/requests.md @@ -423,6 +423,15 @@ To retrieve input values as integers, you may use the `integer` method. This met $perPage = $request->integer('per_page'); ``` + +#### Retrieving Float Input Values + +Similarly, the `float` method retrieves an input value as a float, returning the default value you specify if the input is not present: + +```php +$price = $request->float('price'); +``` + #### Retrieving Clamped Input Values From 172de800ad11596fbb6a15dac2e7b20767254547 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:24:15 +0000 Subject: [PATCH 25/33] Document prohibiting destructive database commands DB::prohibitDestructiveCommands() stops db:wipe, migrate:fresh, migrate:refresh, migrate:reset and migrate:rollback from running, but neither Hypervel's nor Laravel's documentation mentioned it. The prohibition is checked before the production confirmation, so --force cannot bypass it. The migrations docs now show the call under "Forcing Migrations to Run in Production". Upstream reference: laravel/framework master at 588c1c948c and laravel/docs 13.x at 9c2295fc4b. --- src/docs/migrations.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/docs/migrations.md b/src/docs/migrations.md index 5ad5027650..55e1ba920a 100644 --- a/src/docs/migrations.md +++ b/src/docs/migrations.md @@ -248,6 +248,14 @@ Some migration operations are destructive, which means they may cause you to los php artisan migrate --force ``` +To prevent destructive database commands from running in production at all, even with `--force`, call the `DB` facade's `prohibitDestructiveCommands` method from the `boot` method of your application's `AppServiceProvider`. This prohibits the `db:wipe`, `migrate:fresh`, `migrate:refresh`, `migrate:reset`, and `migrate:rollback` commands: + +```php +use Hypervel\Support\Facades\DB; + +DB::prohibitDestructiveCommands($this->app->isProduction()); +``` + ### Rolling Back Migrations From 0ade3eece72a69516ddde30a1e57943f56a86474 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:24:34 +0000 Subject: [PATCH 26/33] Remove historical Hyperf links from package READMEs The package README rules reserve "Ported from" for upstreams a package still tracks, not historical lineage. Twelve package READMEs still linked the Hyperf packages their code originally came from, although Hypervel maintains that code independently. Those links are removed; the licenses are unchanged. Two of these packages do track Laravel. The contracts README keeps only its Laravel Contracts link, and the Redis README now links Laravel's Redis component, whose API the package follows. --- src/context/README.md | 2 -- src/contracts/README.md | 5 +---- src/coordinator/README.md | 2 -- src/core/README.md | 2 -- src/coroutine/README.md | 2 -- src/engine/README.md | 2 -- src/http-server/README.md | 2 -- src/redis/README.md | 4 ++-- src/server-process/README.md | 2 -- src/server/README.md | 2 -- src/signal/README.md | 2 -- src/websocket-server/README.md | 2 -- 12 files changed, 3 insertions(+), 26 deletions(-) diff --git a/src/context/README.md b/src/context/README.md index 8e7b6781ee..0ced384745 100644 --- a/src/context/README.md +++ b/src/context/README.md @@ -2,5 +2,3 @@ Context for Hypervel === [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/context) - -Ported from: https://github.com/hyperf/context diff --git a/src/contracts/README.md b/src/contracts/README.md index 986bf8a71e..6b7a91c6f2 100644 --- a/src/contracts/README.md +++ b/src/contracts/README.md @@ -3,7 +3,4 @@ Contracts for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/contracts) -Ported from: - -- https://github.com/laravel/framework/tree/13.x/src/Illuminate/Contracts -- https://github.com/hyperf/contract +Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Contracts diff --git a/src/coordinator/README.md b/src/coordinator/README.md index 30647372bd..02da672042 100644 --- a/src/coordinator/README.md +++ b/src/coordinator/README.md @@ -2,5 +2,3 @@ Coordinator for Hypervel === [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/coordinator) - -Ported from: https://github.com/hyperf/hyperf diff --git a/src/core/README.md b/src/core/README.md index 4c757f1df5..8ebe2d02bd 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -2,5 +2,3 @@ Framework for Hypervel === [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/framework) - -Ported from: https://github.com/hyperf/hyperf/tree/master/src/framework diff --git a/src/coroutine/README.md b/src/coroutine/README.md index 3f3f46ee21..fbf10c379d 100644 --- a/src/coroutine/README.md +++ b/src/coroutine/README.md @@ -2,5 +2,3 @@ Coroutine for Hypervel === [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/coroutine) - -Ported from: https://github.com/hyperf/coroutine diff --git a/src/engine/README.md b/src/engine/README.md index ae3f80f910..d9cdc14f06 100644 --- a/src/engine/README.md +++ b/src/engine/README.md @@ -3,8 +3,6 @@ Engine for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/engine) -Ported from: https://github.com/hyperf/engine - ## Architecture Hypervel supports Swoole only. Its HTTP, WebSocket, and Reverb servers use the Swoole-specific request and response bridges from `hypervel/http-server`; Hyperf's interchangeable Swoole/Swow HTTP server and response-emitter portability layer is intentionally not part of Engine. diff --git a/src/http-server/README.md b/src/http-server/README.md index ac0cef712f..987d1a2626 100644 --- a/src/http-server/README.md +++ b/src/http-server/README.md @@ -2,5 +2,3 @@ HTTP Server for Hypervel === [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/http-server) - -Ported from: https://github.com/hyperf/hyperf/tree/master/src/http-server diff --git a/src/redis/README.md b/src/redis/README.md index a034f7de47..aad15ac36d 100644 --- a/src/redis/README.md +++ b/src/redis/README.md @@ -3,8 +3,6 @@ Redis for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/redis) -Ported from: https://github.com/hyperf/hyperf/tree/master/src/redis - ## Differences From Laravel - Hypervel uses phpredis-only pooled connections. Cluster and Sentinel settings belong to each named connection instead of Laravel's top-level cluster configuration. @@ -17,3 +15,5 @@ Ported from: https://github.com/hyperf/hyperf/tree/master/src/redis - Native `reset()` is unavailable on pooled connections because it clears authentication and database state owned by the pool. Use `discard()`, `unwatch()`, or `exec()` to finish the corresponding stateful operation. - `Redis::funnel()->acquire()` returns a caller-held concurrency lease that can be refreshed and released explicitly after work spanning multiple operations. Laravel only exposes the callback-scoped funnel API. The Redis concurrency limiter's public `acquire()` returns that lease, so Laravel's protected `acquire($id)`, `lockScript()`, `release($key, $id)`, `releaseScript()`, and `getPrefix()` hooks are not provided. Override `claimSlot()` to customize slot acquisition; the returned lease releases the slot. - Redis funnel and throttle timeout failures throw `Hypervel\Contracts\Limiters\LimiterTimeoutException`, shared with cache funnels. Laravel uses `Illuminate\Contracts\Redis\LimiterTimeoutException` for Redis limiters. + +Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Redis diff --git a/src/server-process/README.md b/src/server-process/README.md index 3f35f7c055..9269fb0340 100644 --- a/src/server-process/README.md +++ b/src/server-process/README.md @@ -4,5 +4,3 @@ Server Process for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/server-process) Documentation: https://hypervel.org/docs/server-processes - -Ported from: https://github.com/hyperf/hyperf/tree/master/src/process diff --git a/src/server/README.md b/src/server/README.md index 7d623b8f49..934d27cb3b 100644 --- a/src/server/README.md +++ b/src/server/README.md @@ -2,5 +2,3 @@ Server for Hypervel === [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/server) - -Ported from: https://github.com/hyperf/hyperf/tree/master/src/server diff --git a/src/signal/README.md b/src/signal/README.md index ef9e9863ca..308b8c366c 100644 --- a/src/signal/README.md +++ b/src/signal/README.md @@ -4,5 +4,3 @@ Signal for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/signal) Documentation: https://hypervel.org/docs/signals - -Ported from: https://github.com/hyperf/hyperf/tree/master/src/signal diff --git a/src/websocket-server/README.md b/src/websocket-server/README.md index c56c57ad6b..cbd2221d91 100644 --- a/src/websocket-server/README.md +++ b/src/websocket-server/README.md @@ -2,5 +2,3 @@ WebSocket Server for Hypervel === [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/websocket-server) - -Ported from: https://github.com/hyperf/hyperf/tree/master/src/websocket-server From 1c93a5da04f1206dd3c4b0d65ff2899bc59581bb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:24:49 +0000 Subject: [PATCH 27/33] Remove a duplicate Recaller test After legacy cookie decoding was removed, the plain-cookie test asserted the same id and token for the same '123|token|hash' input as the existing id and token getter tests. It no longer covered a separate path, so it is removed. Validation: the Recaller test file passes. --- tests/Auth/RecallerTest.php | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/Auth/RecallerTest.php b/tests/Auth/RecallerTest.php index 1f1f579218..d384ee33c4 100644 --- a/tests/Auth/RecallerTest.php +++ b/tests/Auth/RecallerTest.php @@ -86,13 +86,4 @@ public function testValidReturnsFalseWhenIdIsWhitespace(): void $this->assertFalse($recaller->valid()); } - - public function testPlainCookieStringPreservesIdentifierAndToken(): void - { - $raw = '123|token|hash'; - $recaller = new Recaller($raw); - - $this->assertSame('123', $recaller->id()); - $this->assertSame('token', $recaller->token()); - } } From e3d482574a97fecef2309f9355aacbe4d3efa2d3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:24:49 +0000 Subject: [PATCH 28/33] Correct the Redis workflow and unsupported cache driver guidance The redis.yml row in AGENTS.md still named the Cache/Redis and Queue/Redis directories and a chaining/dispatching-only queue rerun, but the workflow now runs the whole Cache and Queue integration directories with Redis selected, plus OpenTelemetry/Redis. The row now lists the directories the workflow actually runs. The unsupported cache driver list omitted APC / APCu, which the porting guide already excludes. It is now listed with the other unsupported drivers. --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 385e278573..35d65b8725 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -729,7 +729,7 @@ Each integration group has its own workflow file in `.github/workflows/`: |----------|------|-----------| | `engine.yml` | HTTP test servers | `tests/Integration/Engine`, `tests/Integration/HttpServer` | | `databases.yml` | MySQL, MariaDB, PostgreSQL, SQLite | `tests/Integration/Database`, `tests/Integration/*/Database/*` | -| `redis.yml` | Redis, Redis Cluster, Valkey | `tests/Integration/Auth/Redis`, `tests/Integration/Broadcasting/Redis`, `tests/Integration/Cache/Redis`, `tests/Integration/Horizon`, `tests/Integration/Http/Redis`, `tests/Integration/Queue/Redis`, `tests/Integration/RateLimiter/Redis`, `tests/Integration/Redis`, `tests/Integration/Session/Redis`; it also reruns the driver-neutral queue chaining and dispatching tests with Redis, the listed topology-neutral Reverb state tests with Cluster, and Reverb state recovery with Valkey | +| `redis.yml` | Redis, Redis Cluster, Valkey | `tests/Integration/Auth/Redis`, `tests/Integration/Broadcasting/Redis`, `tests/Integration/Cache`, `tests/Integration/Horizon`, `tests/Integration/Http/Redis`, `tests/Integration/OpenTelemetry/Redis`, `tests/Integration/Queue`, `tests/Integration/RateLimiter/Redis`, `tests/Integration/Redis`, `tests/Integration/Session/Redis`; Cache and Queue run with Redis selected, and it also reruns the listed topology-neutral Reverb state tests with Cluster and Reverb state recovery with Valkey | | `reverb.yml` | Redis-backed Reverb servers and state | `tests/Integration/Reverb` | | `scout.yml` | Meilisearch, Typesense | `tests/Integration/Scout/*` | @@ -929,7 +929,7 @@ Update upstream test imports to point at the new Fixtures namespace. Tests for these features should be **removed** (not commented out) without asking — they will never be supported: - **Databases:** SQL Server, MongoDB, DynamoDB — Hypervel only supports MySQL, MariaDB, PostgreSQL, and SQLite -- **Cache drivers:** Memcached, DynamoDB, MongoDB +- **Cache drivers:** Memcached, APC / APCu, DynamoDB, MongoDB - **Dynamic connections:** `DB::build()`, `DB::connectUsing()` — incompatible with Swoole connection pooling - **Container access:** ArrayAccess and dynamic service properties From 8954cdf9aedb047ffb4495b5197d4cd8b9421e09 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:30:54 +0000 Subject: [PATCH 29/33] Resolve every Fortify response binding in the binding test The binding test ported from laravel/fortify#701 only checks that each response class implements the contract listed beside it. When it was ported, it replaced a provider test that resolved two of those contracts through the container, so nothing checked that the service provider actually binds each contract to its response. The test now runs on the Fortify application test case and also resolves every contract through the container, checking that it gets the expected response class. The four password reset responses take the broker status as a constructor argument, so the test passes one the same way their controllers do. Validation: the Fortify suite passes under ParaTest and in random order. --- tests/Fortify/ResponseBindingTest.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/Fortify/ResponseBindingTest.php b/tests/Fortify/ResponseBindingTest.php index bee2cb8f53..4c021894b3 100644 --- a/tests/Fortify/ResponseBindingTest.php +++ b/tests/Fortify/ResponseBindingTest.php @@ -7,7 +7,6 @@ use Hypervel\Fortify\Contracts; use Hypervel\Fortify\FortifyServiceProvider; use Hypervel\Fortify\Http\Responses; -use Hypervel\Tests\TestCase; use PHPUnit\Framework\Attributes\DataProvider; class ResponseBindingTest extends TestCase @@ -19,6 +18,13 @@ public function testResponseClassImplementsTheContractItIsBoundTo(string $contra is_a($response, $contract, true), "The [{$response}] class should implement the [{$contract}] contract." ); + + // Password reset responses receive the broker status from their controller. + $this->assertInstanceOf( + $response, + $this->app->make($contract, ['status' => 'passwords.sent']), + "The [{$contract}] contract should resolve to [{$response}]." + ); } /** From 0fa7667b9369379a4cc8ea2d0b324e849c8bdc02 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:30:54 +0000 Subject: [PATCH 30/33] Remove the Fortify test case's unused database hook The Fortify test case defined afterRefreshingDatabase() to create the users, admins and password reset token tables. Every test class that refreshes the database uses the RefreshDatabase trait directly, and a trait method replaces an inherited parent method, so the trait's empty hook or the class's own hook always ran instead. The base hook never ran. The hook is removed. createAdminsTable() stays, since the password confirmation tests call it. Validation: the Fortify suite passes under ParaTest and in random order. --- tests/Fortify/TestCase.php | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/tests/Fortify/TestCase.php b/tests/Fortify/TestCase.php index eb8841c463..d5108c554f 100644 --- a/tests/Fortify/TestCase.php +++ b/tests/Fortify/TestCase.php @@ -59,33 +59,6 @@ protected function defineEnvironment(ApplicationContract $app): void ]); } - /** - * Create fixture tables after refreshing the database. - */ - protected function afterRefreshingDatabase(): void - { - Schema::create('users', function (Blueprint $table): void { - $table->id(); - $table->string('name')->nullable(); - $table->string('email')->unique(); - $table->timestamp('email_verified_at')->nullable(); - $table->string('password')->nullable(); - $table->text('two_factor_secret')->nullable(); - $table->text('two_factor_recovery_codes')->nullable(); - $table->timestamp('two_factor_confirmed_at')->nullable(); - $table->rememberToken(); - $table->timestamps(); - }); - - $this->createAdminsTable(); - - Schema::create('password_reset_tokens', function (Blueprint $table): void { - $table->string('email')->primary(); - $table->string('token'); - $table->timestamp('created_at')->nullable(); - }); - } - /** * Create the admins table. */ From 921ef94eedfd11ad94736eed60f7609f67afc309 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:30:54 +0000 Subject: [PATCH 31/33] Document recovery code generator boot rule and request number defaults Fortify::generateRecoveryCodesUsing() stores its callback for the worker lifetime and its docblock marks it boot-only, but the Fortify docs' list of boot-only methods left it out. It is now listed with the other Fortify callbacks. The request docs said integer() and float() return the given default when the input is missing, but not what they return without one. They now say integer() returns 0 and float() returns 0.0, matching the method signatures. --- src/docs/fortify.md | 1 + src/docs/requests.md | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/docs/fortify.md b/src/docs/fortify.md index 6b39a0f914..60bb27cba0 100644 --- a/src/docs/fortify.md +++ b/src/docs/fortify.md @@ -812,6 +812,7 @@ Call these only during application boot or tests: - `Fortify::updateUserProfileInformationUsing()` - `Fortify::updateUserPasswordsUsing()` - `Fortify::resetUserPasswordsUsing()` +- `Fortify::generateRecoveryCodesUsing()` - `Fortify::redirectUserForTwoFactorAuthenticationUsing()` - `Features::twoFactorAuthentication($options)` - `Features::passkeys($options)` diff --git a/src/docs/requests.md b/src/docs/requests.md index 5019def73b..92104643f0 100644 --- a/src/docs/requests.md +++ b/src/docs/requests.md @@ -417,7 +417,7 @@ $name = $request->string('name')->trim(); #### Retrieving Integer Input Values -To retrieve input values as integers, you may use the `integer` method. This method casts the input value to an integer. If the input is not present, it will return the default value you specify. This is particularly useful for pagination or other numeric inputs: +To retrieve input values as integers, you may use the `integer` method. This method casts the input value to an integer. If the input is not present, it will return the default value you specify, or `0` if you don't specify one. This is particularly useful for pagination or other numeric inputs: ```php $perPage = $request->integer('per_page'); @@ -426,7 +426,7 @@ $perPage = $request->integer('per_page'); #### Retrieving Float Input Values -Similarly, the `float` method retrieves an input value as a float, returning the default value you specify if the input is not present: +Similarly, the `float` method retrieves an input value as a float. If the input is not present, it will return the default value you specify, or `0.0` if you don't specify one: ```php $price = $request->float('price'); From 15bb9f3a58d841172fce20cf54166a3631a9be28 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:30:54 +0000 Subject: [PATCH 32/33] Make two Passkeys test names and a kernel test docblock accurate Two Passkeys route tests said they covered the login and management routes, but they check the login and registration options routes, and deletion is deliberately unthrottled. They are renamed to say login and registration routes. The console kernel test's application kernel described its commands() override as loading the fixture commands directory, but there is no such directory. The test checks that a Laravel-style commands() override can call the protected load() hook, which records the path, so the docblock now says the override loads commands the way a Laravel application kernel does. Validation: both test files pass. --- tests/Foundation/Console/KernelTest.php | 2 +- tests/Passkeys/PasskeysRouteTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Foundation/Console/KernelTest.php b/tests/Foundation/Console/KernelTest.php index 450029713a..ec7b6a8888 100644 --- a/tests/Foundation/Console/KernelTest.php +++ b/tests/Foundation/Console/KernelTest.php @@ -278,7 +278,7 @@ protected function schedule(Schedule $schedule): void } /** - * Load the fixture commands directory. + * Load commands the way a Laravel application kernel does. */ protected function commands(): void { diff --git a/tests/Passkeys/PasskeysRouteTest.php b/tests/Passkeys/PasskeysRouteTest.php index 7306c8bac1..1f9bd389c3 100644 --- a/tests/Passkeys/PasskeysRouteTest.php +++ b/tests/Passkeys/PasskeysRouteTest.php @@ -56,7 +56,7 @@ public function testNullGuardConfigDoesNotAddGuardSelectionMiddleware(): void } #[WithConfig('passkeys.throttle', null)] - public function testNullThrottleOmitsThrottleMiddlewareFromLoginAndManagementRoutes(): void + public function testNullThrottleOmitsThrottleMiddlewareFromLoginAndRegistrationRoutes(): void { foreach (['passkey.login', 'passkey.registration-options'] as $routeName) { $route = Route::getRoutes()->getByName($routeName); @@ -70,7 +70,7 @@ public function testNullThrottleOmitsThrottleMiddlewareFromLoginAndManagementRou } } - public function testOmittedThrottleUsesDefaultMiddlewareOnLoginAndManagementRoutes(): void + public function testOmittedThrottleUsesDefaultMiddlewareOnLoginAndRegistrationRoutes(): void { $config = config()->array('passkeys'); unset($config['throttle']); From 67a09c6cb7bc4d520449e41c5fe890e3c0f2cf4c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:30:54 +0000 Subject: [PATCH 33/33] Store pending passkey options per guard Passkey login and confirmation share one pending verification slot in the session, as upstream does, and registration has its own. Upstream always uses one configured guard, where login requires a guest and confirmation an authenticated user, so only one verification ceremony can be pending. Hypervel's Passkeys use the current request guard, and one session can be signed in on one guard and a guest on another. A guest login on the admin guard and a confirmation on the web guard then wrote to the same key, so the second ceremony replaced the first's challenge and the first failed verification. Registrations on two authenticated guards overwrote each other the same way. The controllers and form requests now suffix both session keys with the selected guard name, like the per-guard password confirmation timestamp. verificationOptions() stays parameterless, and login and confirmation still share one slot within a guard. Two tests issue options for two guards in one session through real routes and read each guard's pending options back through the form requests. Both fail without this change. Validation: the Passkeys and Fortify suites pass under ParaTest; formatting and PHPStan are clean for the changed files. --- .../PasskeyConfirmationController.php | 2 +- .../Controllers/PasskeyLoginController.php | 2 +- .../PasskeyRegistrationController.php | 2 +- .../Requests/PasskeyRegistrationRequest.php | 5 +- .../Requests/PasskeyVerificationRequest.php | 5 +- .../Controllers/PasskeyConfirmationTest.php | 6 +- .../PasskeyLoginControllerTest.php | 6 +- .../Feature/Controllers/PasskeyLoginTest.php | 6 +- .../Controllers/PasskeyRegistrationTest.php | 8 +- tests/Passkeys/PasskeysGuardTest.php | 94 +++++++++++++++++++ 10 files changed, 118 insertions(+), 18 deletions(-) diff --git a/src/passkeys/src/Http/Controllers/PasskeyConfirmationController.php b/src/passkeys/src/Http/Controllers/PasskeyConfirmationController.php index fe4d036327..ca420cba2d 100644 --- a/src/passkeys/src/Http/Controllers/PasskeyConfirmationController.php +++ b/src/passkeys/src/Http/Controllers/PasskeyConfirmationController.php @@ -36,7 +36,7 @@ public function index(Request $request, GenerateVerificationOptions $generate): $serialized = WebAuthn::toJson($options); - $request->session()->put('passkey.verification_options', $serialized); + $request->session()->put('passkey.verification_options_' . Passkeys::guardName(), $serialized); return response()->json([ 'options' => WebAuthn::toBrowserArray($options), diff --git a/src/passkeys/src/Http/Controllers/PasskeyLoginController.php b/src/passkeys/src/Http/Controllers/PasskeyLoginController.php index 59f6af51b3..1aed696530 100644 --- a/src/passkeys/src/Http/Controllers/PasskeyLoginController.php +++ b/src/passkeys/src/Http/Controllers/PasskeyLoginController.php @@ -27,7 +27,7 @@ public function index(Request $request, GenerateVerificationOptions $generate): $serialized = WebAuthn::toJson($options); - $request->session()->put('passkey.verification_options', $serialized); + $request->session()->put('passkey.verification_options_' . Passkeys::guardName(), $serialized); return response()->json([ 'options' => WebAuthn::toBrowserArray($options), diff --git a/src/passkeys/src/Http/Controllers/PasskeyRegistrationController.php b/src/passkeys/src/Http/Controllers/PasskeyRegistrationController.php index 8cc79c5233..86a4f87675 100644 --- a/src/passkeys/src/Http/Controllers/PasskeyRegistrationController.php +++ b/src/passkeys/src/Http/Controllers/PasskeyRegistrationController.php @@ -32,7 +32,7 @@ public function index(Request $request, GenerateRegistrationOptions $generate): $serialized = WebAuthn::toJson($options); - $request->session()->put('passkey.registration_options', $serialized); + $request->session()->put('passkey.registration_options_' . Passkeys::guardName(), $serialized); return response()->json([ 'options' => WebAuthn::toBrowserArray($options), diff --git a/src/passkeys/src/Http/Requests/PasskeyRegistrationRequest.php b/src/passkeys/src/Http/Requests/PasskeyRegistrationRequest.php index 6dbb1317a5..4f7b3fcdbb 100644 --- a/src/passkeys/src/Http/Requests/PasskeyRegistrationRequest.php +++ b/src/passkeys/src/Http/Requests/PasskeyRegistrationRequest.php @@ -5,6 +5,7 @@ namespace Hypervel\Passkeys\Http\Requests; use Hypervel\Foundation\Http\FormRequest; +use Hypervel\Passkeys\Passkeys; use Hypervel\Passkeys\Support\WebAuthn; use Hypervel\Validation\ValidationException; use Throwable; @@ -74,12 +75,14 @@ public function credential(): PublicKeyCredential /** * Get the registration options from the session. * + * Options are stored per guard because one session may hold pending ceremonies for several guards. + * * @throws ValidationException */ public function registrationOptions(): PublicKeyCredentialCreationOptions { /** @var null|string $serialized */ - $serialized = $this->session()->pull('passkey.registration_options'); + $serialized = $this->session()->pull('passkey.registration_options_' . Passkeys::guardName()); if (! is_string($serialized) || $serialized === '') { throw ValidationException::withMessages([ diff --git a/src/passkeys/src/Http/Requests/PasskeyVerificationRequest.php b/src/passkeys/src/Http/Requests/PasskeyVerificationRequest.php index 9307013a19..0746f5b06b 100644 --- a/src/passkeys/src/Http/Requests/PasskeyVerificationRequest.php +++ b/src/passkeys/src/Http/Requests/PasskeyVerificationRequest.php @@ -5,6 +5,7 @@ namespace Hypervel\Passkeys\Http\Requests; use Hypervel\Foundation\Http\FormRequest; +use Hypervel\Passkeys\Passkeys; use Hypervel\Passkeys\Support\WebAuthn; use Hypervel\Validation\ValidationException; use Throwable; @@ -82,12 +83,14 @@ public function remember(): bool /** * Get the verification options from the session. * + * Options are stored per guard because one session may hold pending ceremonies for several guards. + * * @throws ValidationException */ public function verificationOptions(): PublicKeyCredentialRequestOptions { /** @var null|string $serialized */ - $serialized = $this->session()->pull('passkey.verification_options'); + $serialized = $this->session()->pull('passkey.verification_options_' . Passkeys::guardName()); if (! is_string($serialized) || $serialized === '') { throw ValidationException::withMessages([ diff --git a/tests/Passkeys/Feature/Controllers/PasskeyConfirmationTest.php b/tests/Passkeys/Feature/Controllers/PasskeyConfirmationTest.php index efd54bfadd..ebf10f2bcc 100644 --- a/tests/Passkeys/Feature/Controllers/PasskeyConfirmationTest.php +++ b/tests/Passkeys/Feature/Controllers/PasskeyConfirmationTest.php @@ -48,7 +48,7 @@ public function testItStoresConfirmationOptionsInSession(): void ->getJson('/passkeys/confirm/options') ->assertOk(); - $this->assertNotNull(session('passkey.verification_options')); + $this->assertNotNull(session('passkey.verification_options_web')); } public function testItRequiresAuthenticationForConfirmationOptions(): void @@ -84,7 +84,7 @@ public function testItReturnsValidationErrorWhenPasskeyConfirmationIsInvalid(): ->getMock()); $this->actingAs($user) - ->withSession(['passkey.verification_options' => WebAuthn::toJson($this->createRequestOptions())]) + ->withSession(['passkey.verification_options_web' => WebAuthn::toJson($this->createRequestOptions())]) ->postJson('/passkeys/confirm', [ 'credential' => $this->createAssertionCredential(), ]) @@ -116,7 +116,7 @@ public function testItMarksThePasswordAsConfirmedWhenPasskeyConfirmationSucceeds ->getMock()); $this->actingAs($user) - ->withSession(['passkey.verification_options' => WebAuthn::toJson($this->createRequestOptions())]) + ->withSession(['passkey.verification_options_web' => WebAuthn::toJson($this->createRequestOptions())]) ->postJson('/passkeys/confirm', [ 'credential' => $this->createAssertionCredential(), ]) diff --git a/tests/Passkeys/Feature/Controllers/PasskeyLoginControllerTest.php b/tests/Passkeys/Feature/Controllers/PasskeyLoginControllerTest.php index 2c0eaf3603..4082c8ea86 100644 --- a/tests/Passkeys/Feature/Controllers/PasskeyLoginControllerTest.php +++ b/tests/Passkeys/Feature/Controllers/PasskeyLoginControllerTest.php @@ -38,7 +38,7 @@ public function testItDoesNotLogInWhenCustomSignInAuthorizationCallbackReturnsFa Passkeys::authorizeLoginUsing(static fn (): bool => false); - $this->withSession(['passkey.verification_options' => WebAuthn::toJson($this->createRequestOptions())]) + $this->withSession(['passkey.verification_options_web' => WebAuthn::toJson($this->createRequestOptions())]) ->postJson('/passkeys/login', ['credential' => $this->createAssertionCredential()]) ->assertUnprocessable(); @@ -70,7 +70,7 @@ public function testItReturnsTheCustomSignInAuthorizationValidationMessage(): vo ]); }); - $this->withSession(['passkey.verification_options' => WebAuthn::toJson($this->createRequestOptions())]) + $this->withSession(['passkey.verification_options_web' => WebAuthn::toJson($this->createRequestOptions())]) ->postJson('/passkeys/login', ['credential' => $this->createAssertionCredential()]) ->assertUnprocessable() ->assertJsonValidationErrors(['credential' => 'This account has been suspended.']); @@ -99,7 +99,7 @@ public function testItLogsInWhenCustomSignInAuthorizationCallbackReturnsTrue(): Passkeys::authorizeLoginUsing(static fn (): bool => true); - $this->withSession(['passkey.verification_options' => WebAuthn::toJson($this->createRequestOptions())]) + $this->withSession(['passkey.verification_options_web' => WebAuthn::toJson($this->createRequestOptions())]) ->postJson('/passkeys/login', [ 'credential' => $this->createAssertionCredential(), 'remember' => true, diff --git a/tests/Passkeys/Feature/Controllers/PasskeyLoginTest.php b/tests/Passkeys/Feature/Controllers/PasskeyLoginTest.php index 7401a03ef4..0b9fbcfd4b 100644 --- a/tests/Passkeys/Feature/Controllers/PasskeyLoginTest.php +++ b/tests/Passkeys/Feature/Controllers/PasskeyLoginTest.php @@ -34,7 +34,7 @@ public function testItStoresLoginOptionsInSession(): void { $this->getJson('/passkeys/login/options')->assertOk(); - $this->assertNotNull(session('passkey.verification_options')); + $this->assertNotNull(session('passkey.verification_options_web')); } public function testItReturnsValidationErrorWhenPasskeyIsInvalid(): void @@ -45,7 +45,7 @@ public function testItReturnsValidationErrorWhenPasskeyIsInvalid(): void ->andThrow(InvalidPasskeyException::make('Unable to verify passkey. Please try again.')) ->getMock()); - $this->withSession(['passkey.verification_options' => WebAuthn::toJson($this->createRequestOptions())]) + $this->withSession(['passkey.verification_options_web' => WebAuthn::toJson($this->createRequestOptions())]) ->postJson('/passkeys/login', [ 'credential' => $this->createAssertionCredential(), ]) @@ -57,7 +57,7 @@ public function testItReturnsValidationErrorWhenPasskeyIsInvalid(): void public function testItReturnsValidationErrorWhenCredentialFormatIsInvalid(): void { - $this->withSession(['passkey.verification_options' => WebAuthn::toJson($this->createRequestOptions())]) + $this->withSession(['passkey.verification_options_web' => WebAuthn::toJson($this->createRequestOptions())]) ->postJson('/passkeys/login', [ 'credential' => [ 'id' => 'dGVzdC1pZA', diff --git a/tests/Passkeys/Feature/Controllers/PasskeyRegistrationTest.php b/tests/Passkeys/Feature/Controllers/PasskeyRegistrationTest.php index f9b3c6f2ec..a92259b1fb 100644 --- a/tests/Passkeys/Feature/Controllers/PasskeyRegistrationTest.php +++ b/tests/Passkeys/Feature/Controllers/PasskeyRegistrationTest.php @@ -84,7 +84,7 @@ public function testItStoresRegistrationOptionsInSession(): void ->getJson('/user/passkeys/options') ->assertOk(); - $this->assertNotNull(session('passkey.registration_options')); + $this->assertNotNull(session('passkey.registration_options_web')); } public function testRegistrationOptionsRemainInSessionWhenTheAuthenticatedUserChanges(): void @@ -103,13 +103,13 @@ public function testRegistrationOptionsRemainInSessionWhenTheAuthenticatedUserCh ->getJson('/user/passkeys/options') ->assertOk(); - $registrationOptions = session('passkey.registration_options'); + $registrationOptions = session('passkey.registration_options_web'); Passkeys::guard()->logout(); Passkeys::guard()->login($secondUser); $this->assertTrue(Passkeys::guard()->user()?->is($secondUser)); - $this->assertSame($registrationOptions, session('passkey.registration_options')); + $this->assertSame($registrationOptions, session('passkey.registration_options_web')); } public function testItRequiresPasswordConfirmationForRegistrationOptions(): void @@ -162,7 +162,7 @@ public function testItReturnsValidationErrorWhenPasskeyIsInvalid(): void $this->actingAs($user) ->withSession(['auth.password_confirmed_at_web' => time()]) - ->withSession(['passkey.registration_options' => WebAuthn::toJson($this->createRegistrationOptions($user))]) + ->withSession(['passkey.registration_options_web' => WebAuthn::toJson($this->createRegistrationOptions($user))]) ->postJson('/user/passkeys', [ 'name' => 'My Passkey', 'credential' => $this->createRegistrationCredential(), diff --git a/tests/Passkeys/PasskeysGuardTest.php b/tests/Passkeys/PasskeysGuardTest.php index c0ef47b9c7..730d2fb192 100644 --- a/tests/Passkeys/PasskeysGuardTest.php +++ b/tests/Passkeys/PasskeysGuardTest.php @@ -7,10 +7,19 @@ use Hypervel\Auth\EloquentUserProvider; use Hypervel\Contracts\Auth\Factory as AuthFactory; use Hypervel\Contracts\Auth\StatefulGuard; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Passkeys\Actions\VerifyPasskey; use Hypervel\Passkeys\Exceptions\InvalidPasskeyException; +use Hypervel\Passkeys\Http\Controllers\PasskeyConfirmationController; +use Hypervel\Passkeys\Http\Controllers\PasskeyLoginController; +use Hypervel\Passkeys\Http\Controllers\PasskeyRegistrationController; +use Hypervel\Passkeys\Http\Requests\PasskeyRegistrationRequest; +use Hypervel\Passkeys\Http\Requests\PasskeyVerificationRequest; use Hypervel\Passkeys\Passkey; use Hypervel\Passkeys\Passkeys; +use Hypervel\Passkeys\Support\WebAuthn; +use Hypervel\Support\Facades\Route; +use Hypervel\Support\Facades\Schema; use Hypervel\Tests\Passkeys\Fixtures\Admin; use Hypervel\Tests\Passkeys\Fixtures\User; use ParagonIE\ConstantTime\Base64UrlSafe; @@ -88,6 +97,65 @@ public static function ownerTypesOutsideTheSelectedProvider(): array ]; } + public function testLoginAndConfirmationOptionsArePendingSeparatelyForEachGuard(): void + { + $this->configureAdminGuard(); + + Route::middleware(['web', 'guest:admin']) + ->get('/admin/passkeys/login/options', [PasskeyLoginController::class, 'index']); + Route::middleware(['web', 'auth:web']) + ->get('/account/passkeys/confirm/options', [PasskeyConfirmationController::class, 'index']); + + $user = User::create([ + 'name' => 'User', + 'email' => 'user@example.com', + ]); + + $adminLogin = $this->actingAs($user, 'web') + ->getJson('/admin/passkeys/login/options') + ->assertOk(); + + $userConfirmation = $this->getJson('/account/passkeys/confirm/options') + ->assertOk(); + + $this->assertSame($adminLogin->json('options.challenge'), $this->pendingVerificationChallenge('admin')); + $this->assertSame($userConfirmation->json('options.challenge'), $this->pendingVerificationChallenge('web')); + } + + public function testRegistrationOptionsArePendingSeparatelyForEachGuard(): void + { + $this->configureAdminGuard(); + + Schema::create('admins', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->string('email'); + $table->timestamps(); + }); + + Route::middleware(['web', 'auth:web']) + ->get('/account/passkeys/options', [PasskeyRegistrationController::class, 'index']); + Route::middleware(['web', 'auth:admin']) + ->get('/admin/passkeys/options', [PasskeyRegistrationController::class, 'index']); + + $user = User::create([ + 'name' => 'User', + 'email' => 'user@example.com', + ]); + $admin = Admin::create([ + 'name' => 'Admin', + 'email' => 'admin@example.com', + ]); + + $this->actingAs($user, 'web')->actingAs($admin, 'admin'); + + $userRegistration = $this->getJson('/account/passkeys/options')->assertOk(); + $adminRegistration = $this->getJson('/admin/passkeys/options')->assertOk(); + + $this->assertSame($userRegistration->json('options.challenge'), $this->pendingRegistrationChallenge('web')); + $this->assertSame($adminRegistration->json('options.challenge'), $this->pendingRegistrationChallenge('admin')); + } + /** * Configure the admin guard fixture. */ @@ -114,4 +182,30 @@ private function configureAdminGuard(): void ], ]); } + + /** + * Get the challenge from the guard's pending verification options. + */ + private function pendingVerificationChallenge(string $guard): string + { + $this->app->make(AuthFactory::class)->shouldUse($guard); + + $request = PasskeyVerificationRequest::create('/'); + $request->setHypervelSession($this->app->make('session.store')); + + return WebAuthn::toBrowserArray($request->verificationOptions())['challenge']; + } + + /** + * Get the challenge from the guard's pending registration options. + */ + private function pendingRegistrationChallenge(string $guard): string + { + $this->app->make(AuthFactory::class)->shouldUse($guard); + + $request = PasskeyRegistrationRequest::create('/'); + $request->setHypervelSession($this->app->make('session.store')); + + return WebAuthn::toBrowserArray($request->registrationOptions())['challenge']; + } }