Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 23 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ return [

Remove `passkeys` from `auth-kit.fortify.features` to disable Fortify's passkey routes. Auth Kit only enables and configures Fortify; passkey ceremonies, responses, and persistence remain provided by Fortify and `laravel/passkeys`.

### Security defaults

- Two-factor authentication is not enabled by default. MFA is still work in progress.
- Social sign-in only provisions or auto-links accounts when Google, LinkedIn, or PayPal supplies a trusted `email_verified` claim. Facebook and X identities can still be linked by an authenticated user, but cannot establish trust through an email-verification claim.
- Before production, configure HTTPS, secure session cookies, a working mail transport, and Turnstile keys when bot protection is enabled.
- PayPal uses sandbox mode by default. Set `AUTH_KIT_PAYPAL_SANDBOX_MODE=false` with production PayPal credentials before enabling it in production.

## Passkeys

Passkey support uses Fortify's native integration with `laravel/passkeys`. It is stateful and requires the consuming application's authenticatable model to implement Fortify's `PasskeyUser` contract and use Auth Kit's morph-aware `PasskeyAuthenticatable` trait:
Expand Down Expand Up @@ -112,22 +119,22 @@ The application client should use Fortify's `/passkeys/login/options`, `/passkey

## Actions

| Action | Purpose |
|-----------------------------|----------------------------------------------------------------|
| `AttemptEmailPasswordLogin` | Verify email + password against a guard, returns `AuthResult` |
| `LoginUser` | Log user into session + regenerate session |
| `LogoutUser` | Log out + invalidate session |
| `CreateNewUser` | Validate and create user (Fortify `CreatesNewUsers`) |
| `ResetUserPassword` | Validate and reset password (Fortify `ResetsUserPasswords`) |
| Action | Purpose |
|--------------------------------|-----------------------------------------------------------------------|
| `AttemptEmailPasswordLogin` | Verify email + password against a guard, returns `AuthResult` |
| `LoginUser` | Log user into session + regenerate session |
| `LogoutUser` | Log out + invalidate session |
| `CreateNewUser` | Validate and create user (Fortify `CreatesNewUsers`) |
| `ResetUserPassword` | Validate and reset password (Fortify `ResetsUserPasswords`) |
| `UpdateUserProfileInformation` | Validate and update profile (Fortify `UpdatesUserProfileInformation`) |
| `UpdateUserPassword` | Validate and update password (Fortify `UpdatesUserPasswords`) |
| `IssueTokenForUser` | Issue Sanctum personal access token, returns `TokenResult` |
| `CheckEmailExists` | Check if email is registered |
| `FindUserByEmail` | Retrieve user by email |
| `ResolveSocialIdentity` | Safe social identity → user resolution (verified-email check) |
| `SocialRedirectAction` | Generate OAuth redirect URL, returns `SocialRedirectResult` |
| `SocialCallbackAction` | Handle OAuth callback via `ResolveSocialIdentity` |
| `CreateSocialAccountAction` | Create social account record via polymorphic relation |
| `UpdateUserPassword` | Validate and update password (Fortify `UpdatesUserPasswords`) |
| `IssueTokenForUser` | Issue Sanctum personal access token, returns `TokenResult` |
| `CheckEmailExists` | Check if email is registered |
| `FindUserByEmail` | Retrieve user by email |
| `ResolveSocialIdentity` | Safe social identity → user resolution (verified-email check) |
| `SocialRedirectAction` | Generate OAuth redirect URL, returns `SocialRedirectResult` |
| `SocialCallbackAction` | Handle OAuth callback via `ResolveSocialIdentity` |
| `CreateSocialAccountAction` | Create social account record via polymorphic relation |

## Result types

Expand Down Expand Up @@ -164,7 +171,7 @@ Extend these to wire up your own routes. JSON responses are handled automaticall
| `AbstractLogoutController` | `loggedOut()` |
| `AbstractRegisterController` | `registered()` |
| `AbstractSocialRedirectController` | `redirect()` |
| `AbstractSocialCallbackController` | `passed()`, `failed()` |
| `AbstractSocialCallbackController` | `passed()`, `failed()` |

## Social login

Expand Down
14 changes: 9 additions & 5 deletions src/Actions/ResolveSocialIdentity.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public function execute(SocialProvider $provider, SocialiteUser $socialUser, str
return auth()->user();
}

$email = $this->normalizedVerifiedEmail($socialUser);
$email = $this->normalizedVerifiedEmail($provider, $socialUser);

if ($email !== null && ($existingUser = $this->findUserByEmail($userModel, $email)) !== null) {
$this->createSocialAccount->execute(
Expand All @@ -80,19 +80,23 @@ public function execute(SocialProvider $provider, SocialiteUser $socialUser, str
return $user;
}

private function normalizedVerifiedEmail(SocialiteUser $socialUser): ?string
private function normalizedVerifiedEmail(SocialProvider $provider, SocialiteUser $socialUser): ?string
{
$email = $socialUser->getEmail();

if ($email === null || ! $this->emailIsVerified($socialUser)) {
if ($email === null || ! $this->emailIsVerified($provider, $socialUser)) {
return null;
}

return Str::lower($email);
}

private function emailIsVerified(SocialiteUser $socialUser): bool
private function emailIsVerified(SocialProvider $provider, SocialiteUser $socialUser): bool
{
if (! in_array($provider, [SocialProvider::GOOGLE, SocialProvider::LINKEDIN, SocialProvider::PAYPAL], true)) {
return false;
}

$rawUser = $socialUser instanceof \Laravel\Socialite\AbstractUser
? $socialUser->getRaw()
: [];
Expand All @@ -101,7 +105,7 @@ private function emailIsVerified(SocialiteUser $socialUser): bool
return false;
}

return filter_var($rawUser['email_verified'] ?? $rawUser['verified_email'] ?? false, FILTER_VALIDATE_BOOLEAN);
return filter_var($rawUser['email_verified'] ?? false, FILTER_VALIDATE_BOOLEAN);
}

private function findUserByEmail(string $userModel, ?string $email): ?Authenticatable
Expand Down
20 changes: 6 additions & 14 deletions src/Http/Controllers/AbstractPasswordResetLinkController.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,31 +24,23 @@ public function store(Request $request): mixed
$email = Str::lower($email);
}

$status = $this->broker()->sendResetLink(['email' => $email]);
$this->broker()->sendResetLink(['email' => $email]);

if ($request->expectsJson()) {
return $status === Password::RESET_LINK_SENT
? $this->jsonResponse(status: 'passed', data: ['message' => __($status)])
: $this->jsonResponse(status: 'failed', data: ['message' => __($status)], code: 422);
return $this->jsonResponse(
status: 'passed',
data: ['message' => __(Password::RESET_LINK_SENT)],
);
}

return $status === Password::RESET_LINK_SENT
? $this->sent(request: $request, status: $status)
: $this->failed(request: $request, status: $status);
return $this->sent(request: $request, status: Password::RESET_LINK_SENT);
}

protected function sent(Request $request, string $status): mixed
{
return back()->with('status', __($status));
}

protected function failed(Request $request, string $status): mixed
{
return back()
->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}

protected function broker(): PasswordBroker
{
return Password::broker(config('fortify.passwords'));
Expand Down
33 changes: 33 additions & 0 deletions tests/Feature/PasswordResetLinkControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

use Illuminate\Http\Request;
use Workbench\App\Models\User;
use Illuminate\Support\Facades\Notification;
use Simtabi\Laranail\Auth\Http\Controllers\AbstractPasswordResetLinkController;

it('returns the same response for known and unknown password reset email addresses', function (): void {
Notification::fake();

User::factory()->create(['email' => 'known@example.com']);

$controller = new class () extends AbstractPasswordResetLinkController {};

$knownResponse = $controller->store(jsonRequest(['email' => 'known@example.com']));
$unknownResponse = $controller->store(jsonRequest(['email' => 'unknown@example.com']));

expect($knownResponse->getStatusCode())->toBe(200)
->and($unknownResponse->getStatusCode())->toBe(200)
->and($unknownResponse->getData(true))->toBe($knownResponse->getData(true));
});

function jsonRequest(array $data): Request
{
return Request::create(
uri: '/forgot-password',
method: 'POST',
parameters: $data,
server: ['HTTP_ACCEPT' => 'application/json'],
);
}
24 changes: 24 additions & 0 deletions tests/Feature/ResolveSocialIdentityTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,30 @@ function socialiteUserWithoutVerification(): SocialiteUser
->and(Social::query()->count())->toBe(0);
});

it('does not trust an email verification claim from Facebook', function (): void {
User::factory()->create(['email' => 'john@example.com']);

$user = app(ResolveSocialIdentity::class)->execute(
provider: SocialProvider::FACEBOOK,
socialUser: socialiteUser(['email_verified' => true]),
guard: 'web',
);

expect($user)->toBeNull()
->and(Social::query()->count())->toBe(0);
});

it('accepts LinkedIns documented email verification claim', function (): void {
$user = app(ResolveSocialIdentity::class)->execute(
provider: SocialProvider::LINKEDIN,
socialUser: socialiteUser(['email_verified' => 'true']),
guard: 'web',
);

expect($user)->not->toBeNull()
->and($user->email)->toBe('john@example.com');
});

it('does not auto-link when raw user has no verification field', function (): void {
User::factory()->create(['email' => 'john@example.com']);

Expand Down