Work on the Auth module (modules/auth) — login, registration, magic link, password reset, email verification, Socialite OAuth, impersonation, and the Profile/Security settings panels. Use when changing any of these flows, their routes, settings, or tests.
php artisan boost:add-skill saucebase/auth
Save this content to: .claude/skills/saucebase-auth-development/SKILL.md
# Auth Module
Paths are relative to `modules/auth/` unless they say otherwise. Commands run from the application root.
Authentication, registration, magic link (passwordless), passkeys (WebAuthn, via `laravel/passkeys`), password reset, email verification, OAuth (Socialite), and user impersonation.
## Key Files
| Layer | Files |
|-------|-------|
| Controllers | `LoginController`, `RegisterController`, `SocialiteController`, `ForgotPasswordController`, `ResetPasswordController`, `VerifyEmailController`, `EmailVerificationNotificationController`, `EmailVerificationPromptController`, `PasswordController`, `ProfileController`, `ReimpersonateController`, `MagicLinkController`, `PasskeyLoginController`, `PasskeyController`, `ConfirmPasswordController` |
| Models | `SocialAccount` (provider, tokens, avatar, last_login_at), `MagicLinkToken` (hashed token, expires_at, used_at), `Laravel\Passkeys\Passkey` (the package's; table shipped by this module) |
| Service | `SocialiteService` — all OAuth logic (find/create user, link/disconnect accounts) |
| Requests | `LoginRequest` (credential validation + rate limiting), `RegisterRequest` (password hashing in `passedValidation`), `UpdateProfileInfoRequest`, `UpdateProfileAvatarRequest`, `UpdatePasswordRequest` |
| Exceptions | `AuthException` (credentials, throttle), `SocialiteException` (disconnect, account linking, provider validation, registration disabled) |
| Listeners | `AssignUserRole` (Registered), `UpdateUserLastLogin` (Login), `Impersonation` (`EnterImpersonation` — session history), `SendLoginNotification` (`ReturningUserAuthenticated`), `SendWelcomeNotification` (Registered) |
| Notifications | `WelcomeNotification` (Registered), `MagicLinkNotification` (passwordless login link with configured expiry), `LoginNotification` (new sign-in, only when `login_notification_enabled`), `PasskeyAddedNotification` (always, when a passkey is added) |
| Events | `ReturningUserAuthenticated` — dispatched by password, magic-link and social sign-in, not by registration |
| Middleware | `EnsureRegistrationEnabled`, `EnsureMagicLinkEnabled`, `EnsurePasskeysEnabled`, `EnsureSocialiteProviderEnabled` — each 404s its routes while the setting is off |
| Settings | `AuthSettings` (`registration_enabled`, `modal_enabled`, `magic_link_enabled`, `magic_link_expiry`, `login_notification_enabled`, `passkeys_enabled`, `enabled_socialite_providers`) |
| Settings sections | `ProfileSection` (order 10, `Auth::SettingsProfile`), `SecuritySection` (order 15, `Auth::SettingsSecurity`) — panels in core's settings modal |
| Trait | `Sociable` — added to User model (socialAccounts relation, connected_providers, disconnect); `user.patch` also adds the package's `PasskeyAuthenticatable` + `PasskeyUser` |
| Filament | `AuthPlugin`, `AuthenticationSettings`, `UserResource` (list, create, view, edit), `UserForm`, `UsersTable` |
| Pages | `Login`, `Register`, `ForgotPassword`, `ResetPassword`, `VerifyEmail`, `MagicLink`, `SettingsProfile` + `SettingsSecurity` (settings modal panels) |
| Layout | `AuthCardLayout` — card with logo, status alerts, page transitions |
| Component | `SocialiteProviders` — Google/GitHub buttons with divider, `PasskeyLoginButton` — passkey sign-in under the login form, `PageHeader` — title with optional back link |
## Frontend
Pages, layouts and components live under `resources/js/`. The module's `resources/js/app` entry registers its icons, actions and global components with `registerIcon()`, `registerAction()` and `registerGlobalComponent()` in `setup()`.
## Routes
**Guest routes** (`/auth/*`): login (GET/POST), register (GET/POST), forgot-password (GET/POST, throttle:6,1), reset-password/{token} (GET, signed), reset-password (POST, throttle:6,1), magic-link (GET/POST, throttle:5,1), `auth.passkeys.options` — passkeys/options (GET) and `auth.passkeys.login` — passkeys/login (POST), both JSON, `throttle:passkeys` (10/min, own counter)
**Auth routes** (`/auth/*`): logout (POST — a GET logout is reachable from any other site's markup), verify-email (GET), verify-email/{id}/{hash} (GET, signed), email/verification-notification (POST, throttle:6,1), password (PUT)
**Socialite** (outside guest/auth groups): `auth.socialite.redirect` (GET), `auth.socialite.callback` (GET), `auth.socialite.disconnect` (DELETE, auth)
**Magic Link** (outside guest/auth groups): `magic-link.authenticate` — `/auth/magic-link/{token}` (GET) — must be accessible from email clients
**Account settings** (`/settings/*`, middleware `auth`, `verified`): `settings.profile` (redirects to the `#settings/profile` fragment), `settings.profile.update-info` (PATCH), `settings.profile.update-avatar` (POST), `settings.profile.delete-avatar` (DELETE), `settings.profile.password.update` (PUT), `password.confirm` — `/settings/confirm-password` (GET, where Laravel's `password.confirm` middleware sends what it stops) and `password.confirm.store` (POST, `throttle:6,1`), `settings.passkeys.confirm.options` (GET) and `settings.passkeys.confirm` (POST) — confirm with a passkey, `throttle:passkeys`, not behind the setting, `settings.passkeys.options` (GET, JSON) and `settings.passkeys.store` (POST, JSON) behind the setting, `throttle:passkeys` and `password.confirm`, `settings.passkeys.destroy` — `/settings/passkeys/{passkeyId}` (DELETE, `password.confirm`, not behind the setting)
The routes are grouped under `settings.profile.*` for history; password and
socialite disconnect are rendered by the **Security** panel, not Profile.
**Impersonation**: `auth.impersonate.reimpersonate` — `/auth/impersonate/{userId}` (POST, auth)
## Patterns
### Changing Your Email Drops Its Verification
`ProfileController::updateInfo()` clears `email_verified_at` when the address actually
changed. `email_verified_at` is deliberately **not** in the User model's `$fillable`, so no
request payload can grant itself a verified address; `SocialiteService` sets it with
`forceFill()`.
Changing a password does **not** yet end other sessions — that needs `AuthenticateSession`
in the `web` group, which is an app-wide change awaiting a regression pass (sc-714).
Note that `User` does not implement `MustVerifyEmail` (the import is commented out), so the
`verified` middleware is currently a pass-through and the verification routes are not
enforced. The timestamp is still kept accurate for when that changes.
### Socialite Dual-Flow
`SocialiteController::callback()` checks `Auth::check()` to branch:
- **Guest**: finds/creates user via `SocialiteService::handleCallback()`, logs in, redirects to intended URL
- **Authenticated**: links account via `SocialiteService::linkAccountToUser()`, redirects to `settings.profile` (this module owns that route)
### Disconnect Validation
Cannot disconnect if it's the user's only login method. `SocialiteService::disconnectProvider()` throws `SocialiteException::cannotDisconnectOnlyMethod()` when `socialAccounts->count() === 1 && !$user->password`.
Also prevents account takeover: linking a social ID already owned by another user throws `accountAlreadyLinked`.
### Every Way In Lands on Home
Login, register, magic link, socialite and verification redirect to
`redirect()->intended(Home::url($request, <reason>))`, with the reason `login`,
`registered` (a new account, including a first social sign-in) or `verified`, so the app
can send a new account to onboarding. The welcome email links to `route('home')`, which is
resolved when clicked.
### Passkeys
Built on `laravel/passkeys` (server) and `@laravel/passkeys` (browser client, Vue and React hooks).
Both are pre-1.0, so pin and expect upgrade work. The module calls `Passkeys::ignoreRoutes()`
in `AuthServiceProvider::register()` and registers its own routes, so they sit behind
`passkeys_enabled` (on by default) and sign in through `Controller::signIn()` like every
other method — the login notification and welcome-back toast included — then answer
`{redirect: intended(Home::url(..., login))}`. The client calls them with `fetch`, so they
answer JSON and the login button follows the redirect with a full page load (the session was
regenerated). The package's own Actions (`GenerateVerificationOptions`, `VerifyPasskey`,
`GenerateRegistrationOptions`, `StorePasskey`, `DeletePasskey`) and FormRequests do the
WebAuthn work; tests stub `VerifyPasskey`/`StorePasskey`.
- The sign-in challenge names no account, so it reveals nothing about which emails exist.
- **Adding** a passkey is refused (403) while impersonating, and the panel hides the form
(`can_add_passkeys`): it would outlive the impersonation as a way back into the account.
- Adding or removing one needs a recent confirmation (`password.confirm`); see below. Adding
one also emails the owner (`PasskeyAddedNotification`), queued with `beforeCommit()` in the
same transaction as the passkey, so a queue failure keeps no passkey.
- **Removing** is not behind the setting, nor is confirming with a passkey, and the Security
panel lists passkeys while the user still has some: a passkey added before the feature was
switched off can still be removed, by a user with no password too.
Removal is scoped to the user's own relation (404 otherwise). The route parameter is
`passkeyId`, not `passkey`: the package binds `{passkey}` globally to any user's passkey.
- Passkeys don't count as a login method for `SocialiteService::disconnectProvider()`, and
nothing stops a user removing their last passkey; forgot-password is the way back in.
- The `passkeys` table migration is shipped by this module (the package only publishes one).
- Passkeys are bound to the relying party ID (the host in `APP_URL`) and need HTTPS or
`localhost`. Changing the domain invalidates every passkey.
### Confirming it's you
Adding or removing a passkey, and changing the email, need fresh proof: Laravel's
`password.confirm` and `auth.password_timeout`, the same `auth.password_confirmed_at` session key.
- `ConfirmIdentityDialog` (Vue and React) takes the password (`password.confirm.store`) or a
passkey (the package's `PasskeyConfirmationController`). It posts over JSON through core's
`useHttp`, never as an Inertia visit: a visit reloads the panel and overwrites the passkey
challenge in the session.
- The panels get `password_confirmed_until` and `password_timeout` (`PasswordConfirmation::props()`),
never a boolean, and ask again 30s early (`lib/passwordConfirmation.ts`). An email change is
checked in `ProfileController`, since a route middleware can't tell it from a name change.
- When the server still refuses: `confirm_identity` comes back as a validation error (email
change, and `ConfirmPasswordController::show()` for a stopped Inertia visit), and the panel
reopens the dialog and retries. A failed add only marks the session unconfirmed: the
passkeys client keeps no status code.
- `ForgetPasswordConfirmation` drops the confirmation on entering and leaving impersonation,
so one account's password never vouches for another.
### Rate Limiting
`LoginRequest::ensureIsNotRateLimited()` — 5 attempts per `email|ip` key. Fires `Lockout` event, throws `AuthException::throttle($seconds)`. Cleared on success. Covered in PHP (`LoginTest`), not e2e: Playwright workers share one IP, so a lockout there leaks into other login tests.
### Users Admin Permissions
`manage users` opens the Users page (`UserResource::canAccess()`); `impersonate users` is separate. Both come from `Database\Seeders\DatabaseSeeder`, granted to nobody. `AuthenticationSettings` is a site-wide settings page (`manage settings`, from core's `SettingsPage`).
Staff with `manage users` can never reach an admin, and one admin always remains:
- `UserPolicy` refuses `update`, `delete` and `impersonate` on an admin. `admin` passes every check through `Gate::before`, so it binds only non-admins.
- The Role field is visible only to admins, and disabled on their own record. A hidden or disabled field is not saved, so a forged value is ignored. A user created by someone who cannot assign roles gets `user` (`CreateUser::afterCreate()`).
- Nobody deletes their own account here (`UserResource::canDelete()`), which the delete button and each row of the bulk delete both ask.
- Every way to impersonate asks `can('impersonate', $target)`: the Filament action (`Impersonate::configureUsing()` in the provider), `ReimpersonateController`, and tenancy's handoff.
### Impersonation
Uses `stechstudio/filament-impersonate`. Session stores history at `impersonation.recent_history` (max 4 user IDs). `ReimpersonateController` lets admins re-impersonate from recent list (max 3 shown in UI, filters deleted users and self). Stop via `filament-impersonate.leave` route.
### Magic Link Flow
`MagicLinkController::store()` silently finds the user by email (no error on unknown email). If found: deletes existing tokens for that user, creates a new `MagicLinkToken` (token = SHA-256 hash of `Str::random(64)`, expiry controlled by `AuthSettings`), and sends `MagicLinkNotification` with the plain-token URL.
`MagicLinkController::authenticate()` hashes the incoming token and consumes it in one atomic `UPDATE` (unused and unexpired only), so a link works once even under concurrent clicks, then logs in the user and redirects to the intended URL or home.
`AuthSettings` is auto-discovered from `src/Settings`, with defaults installed
from `database/settings`. Magic links are disabled by default, with a 15-minute
expiry once switched on. Administrators manage both values through the `AuthenticationSettings`
Filament page under the Settings navigation group.
**Token storage:** Plain token only lives in the email link. DB stores `hash('sha256', $plainToken)`. This means even if the DB is compromised, tokens cannot be forged or replayed.
### Account Settings Routes Keep the `settings.*` Names
Profile management moved here from the retired `settings` module. The routes deliberately
keep the `/settings/*` URLs and `settings.*` names, because app core resolves them by name,
and `PasswordChangedNotification` links to `route('settings.profile')`. That route now
redirects into the `#settings/profile` fragment rather than rendering a page.
The settings *shell* is core's modal (`pages/Settings`, `useSettingsModal`); this module
only contributes `ProfileSection` and `SecuritySection`. Core discovers them from
`src/Settings`, so `NavUser` no longer guards on `route().has('settings.profile')` — it
guards on whether any section was contributed at all.
### Profile Is Identity, Security Is Credentials
`SettingsProfile` holds name, email, and avatar. `SettingsSecurity` holds the password form
and connected social accounts. The split is by the question being asked, not by ownership:
both sections live here, and `SecuritySection` sits at order 15 so it follows Profile (10)
ahead of anything another module contributes.
### Login and Register Open in a Modal Only When Asked
Both URLs stay canonical pages. `LoginController::create()` and `RegisterController::create()`
return `Inertia::render()` unless **both** `AuthSettings::$modal_enabled` is on and the request
carries `Modal::HEADER_MODAL`; only then do they return `Inertia::modal()` with a `modal: true`
prop. There are no modal-only components — the page components branch on that prop, and the
form closes the modal through `useModal()` on success.
Branching on the header explicitly matters: `Modal::toResponse()` falls back to the **referer**
when the header is absent, which would turn any ordinary in-app link to `/auth/login` into a
modal over the previous page.
The modal's **base-URL** header counts as asking too. A form in the modal that fails
validation is redirected back to the modal's own URL, and the browser follows that with
the base-URL header but not the modal header; without it the modal came back as the full
page. The client sends that header only while a modal is open. Login errors are
validation errors on `email` for the same reason: a flashed `error` lands on the page
behind the modal, where nothing shows it.
`AuthLink` swaps sibling screens by closing the modal and opening the next one only after
the close has navigated back to the page behind. Opened sooner, the sibling records the
closing modal's URL as its base and is torn down by that navigation. A second click while
a swap is in flight is ignored.
`AuthServiceProvider` shares the setting as `auth.modal_enabled`, and core's `Header`
reads it to swap `Link` for `ModalLink` on the navbar Sign In and Get Started
entries — `ModalLink` is what sends the header. Every other link to those URLs stays a page.
`PasswordController::update()` is shared by two routes: `password.update` (`PUT /auth/password`)
and `settings.profile.password.update`. It redirects to `settings.profile` on success.
### Logout Action Handler
The module's `resources/js/app` entry registers the `logout`, `settings`, and `profile` icons plus the logout action handler via `registerIcon()` and `registerAction()` from `@saucebase/vue/lib/navigation` (or `@saucebase/react/...`). The action shows a confirmation dialog and posts to `route('logout')`.
## Commands
```bash
php artisan auth:make-admin {email?} # promote an existing user to admin
```
## ENV Variables
```
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_CLIENT_REDIRECT_URI
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_CLIENT_REDIRECT_URI
```
Redirect URIs default to `/auth/socialite/{provider}/callback`. Providers configured in `config/services.php`.
## Testing
```bash
php artisan test --testsuite=Modules --filter='^Modules\\Auth\\Tests' # PHPUnit
npx playwright test --project="@auth*" # E2E
```
**E2E coverage**: login (basic, errors, security (CSRF), social, modal, logout), register (basic, errors), forgot-password (basic, errors), verify-email, profile (avatar, socialite settings under the Security panel), passkeys (add, sign in, remove, each confirmed — Chromium's virtual authenticator, so Chromium only), confirm-identity (email change). Page objects in `tests/e2e/pages/`; specs import the shared `test` fixture from `@e2e/fixtures`.
## Gotchas
- `LoginRequest::validateCredentials()` validates without logging in — the controller handles `Auth::login()` separately
- Social users get a random password and are **not** marked verified — signing in with a provider is not proof of the address, and nobody has confirmed it (sc-713)
- Socialite redirect/callback routes are outside both guest and auth middleware groups
- `RegisterRequest::passedValidation()` hashes the password before the controller sees it
- Filament UserResource enforces single role (maxItems: 1) despite multi-select UI
- Magic link authenticate route is outside both guest and auth middleware groups (link is clicked from email client)
- `modal_enabled` is presentation only. Turning it off does not change what the routes do, only how they are reached — the canonical URLs work either way, which is why there is no separate modal route.
- `registration_enabled` closes both signup paths: `EnsureRegistrationEnabled` 404s the register routes, and `SocialiteService::handleCallback()` throws `registrationDisabled()` rather than creating a new user (existing users still sign in). Login is deliberately not toggleable — disabling it would lock out admins.
Paths are relative to modules/auth/ unless they say otherwise. Commands run from the application root.
Authentication, registration, magic link (passwordless), passkeys (WebAuthn, via laravel/passkeys), password reset, email verification, OAuth (Socialite), and user impersonation.
| Layer | Files |
|---|---|
| Controllers | LoginController, RegisterController, SocialiteController, ForgotPasswordController, ResetPasswordController, VerifyEmailController, EmailVerificationNotificationController, EmailVerificationPromptController, PasswordController, ProfileController, ReimpersonateController, MagicLinkController, PasskeyLoginController, PasskeyController, ConfirmPasswordController |
| Models | SocialAccount (provider, tokens, avatar, last_login_at), MagicLinkToken (hashed token, expires_at, used_at), Laravel\Passkeys\Passkey (the package's; table shipped by this module) |
| Service | SocialiteService — all OAuth logic (find/create user, link/disconnect accounts) |
| Requests | LoginRequest (credential validation + rate limiting), RegisterRequest (password hashing in passedValidation), UpdateProfileInfoRequest, UpdateProfileAvatarRequest, UpdatePasswordRequest |
| Exceptions | AuthException (credentials, throttle), SocialiteException (disconnect, account linking, provider validation, registration disabled) |
| Listeners | AssignUserRole (Registered), UpdateUserLastLogin (Login), Impersonation (EnterImpersonation — session history), SendLoginNotification (ReturningUserAuthenticated), SendWelcomeNotification (Registered) |
| Notifications | WelcomeNotification (Registered), MagicLinkNotification (passwordless login link with configured expiry), LoginNotification (new sign-in, only when login_notification_enabled), PasskeyAddedNotification (always, when a passkey is added) |
| Events | ReturningUserAuthenticated — dispatched by password, magic-link and social sign-in, not by registration |
| Middleware | EnsureRegistrationEnabled, EnsureMagicLinkEnabled, EnsurePasskeysEnabled, EnsureSocialiteProviderEnabled — each 404s its routes while the setting is off |
| Settings | AuthSettings (registration_enabled, modal_enabled, magic_link_enabled, magic_link_expiry, login_notification_enabled, passkeys_enabled, enabled_socialite_providers) |
| Settings sections | ProfileSection (order 10, Auth::SettingsProfile), SecuritySection (order 15, Auth::SettingsSecurity) — panels in core's settings modal |
| Trait | Sociable — added to User model (socialAccounts relation, connected_providers, disconnect); user.patch also adds the package's PasskeyAuthenticatable + PasskeyUser |
| Filament | AuthPlugin, AuthenticationSettings, UserResource (list, create, view, edit), UserForm, UsersTable |
| Pages | Login, Register, ForgotPassword, ResetPassword, VerifyEmail, MagicLink, SettingsProfile + SettingsSecurity (settings modal panels) |
| Layout | AuthCardLayout — card with logo, status alerts, page transitions |
| Component | SocialiteProviders — Google/GitHub buttons with divider, PasskeyLoginButton — passkey sign-in under the login form, PageHeader — title with optional back link |
Pages, layouts and components live under resources/js/. The module's resources/js/app entry registers its icons, actions and global components with registerIcon(), registerAction() and registerGlobalComponent() in setup().
Guest routes (/auth/*): login (GET/POST), register (GET/POST), forgot-password (GET/POST, throttle:6,1), reset-password/{token} (GET, signed), reset-password (POST, throttle:6,1), magic-link (GET/POST, throttle:5,1), auth.passkeys.options — passkeys/options (GET) and auth.passkeys.login — passkeys/login (POST), both JSON, throttle:passkeys (10/min, own counter)
Auth routes (/auth/*): logout (POST — a GET logout is reachable from any other site's markup), verify-email (GET), verify-email/{id}/{hash} (GET, signed), email/verification-notification (POST, throttle:6,1), password (PUT)
Socialite (outside guest/auth groups): auth.socialite.redirect (GET), auth.socialite.callback (GET), auth.socialite.disconnect (DELETE, auth)
Magic Link (outside guest/auth groups): magic-link.authenticate — /auth/magic-link/{token} (GET) — must be accessible from email clients
Account settings (/settings/*, middleware auth, verified): settings.profile (redirects to the #settings/profile fragment), settings.profile.update-info (PATCH), settings.profile.update-avatar (POST), settings.profile.delete-avatar (DELETE), settings.profile.password.update (PUT), password.confirm — /settings/confirm-password (GET, where Laravel's password.confirm middleware sends what it stops) and password.confirm.store (POST, throttle:6,1), settings.passkeys.confirm.options (GET) and settings.passkeys.confirm (POST) — confirm with a passkey, throttle:passkeys, not behind the setting, settings.passkeys.options (GET, JSON) and settings.passkeys.store (POST, JSON) behind the setting, throttle:passkeys and password.confirm, settings.passkeys.destroy — /settings/passkeys/{passkeyId} (DELETE, password.confirm, not behind the setting)
The routes are grouped under settings.profile.* for history; password and
socialite disconnect are rendered by the Security panel, not Profile.
Impersonation: auth.impersonate.reimpersonate — /auth/impersonate/{userId} (POST, auth)
ProfileController::updateInfo() clears email_verified_at when the address actually
changed. email_verified_at is deliberately not in the User model's $fillable, so no
request payload can grant itself a verified address; SocialiteService sets it with
forceFill().
Changing a password does not yet end other sessions — that needs AuthenticateSession
in the web group, which is an app-wide change awaiting a regression pass (sc-714).
Note that User does not implement MustVerifyEmail (the import is commented out), so the
verified middleware is currently a pass-through and the verification routes are not
enforced. The timestamp is still kept accurate for when that changes.
SocialiteController::callback() checks Auth::check() to branch:
SocialiteService::handleCallback(), logs in, redirects to intended URLSocialiteService::linkAccountToUser(), redirects to settings.profile (this module owns that route)Cannot disconnect if it's the user's only login method. SocialiteService::disconnectProvider() throws SocialiteException::cannotDisconnectOnlyMethod() when socialAccounts->count() === 1 && !$user->password.
Also prevents account takeover: linking a social ID already owned by another user throws accountAlreadyLinked.
Login, register, magic link, socialite and verification redirect to
redirect()->intended(Home::url($request, <reason>)), with the reason login,
registered (a new account, including a first social sign-in) or verified, so the app
can send a new account to onboarding. The welcome email links to route('home'), which is
resolved when clicked.
Built on laravel/passkeys (server) and @laravel/passkeys (browser client, Vue and React hooks).
Both are pre-1.0, so pin and expect upgrade work. The module calls Passkeys::ignoreRoutes()
in AuthServiceProvider::register() and registers its own routes, so they sit behind
passkeys_enabled (on by default) and sign in through Controller::signIn() like every
other method — the login notification and welcome-back toast included — then answer
{redirect: intended(Home::url(..., login))}. The client calls them with fetch, so they
answer JSON and the login button follows the redirect with a full page load (the session was
regenerated). The package's own Actions (GenerateVerificationOptions, VerifyPasskey,
GenerateRegistrationOptions, StorePasskey, DeletePasskey) and FormRequests do the
WebAuthn work; tests stub VerifyPasskey/StorePasskey.
can_add_passkeys): it would outlive the impersonation as a way back into the account.password.confirm); see below. Adding
one also emails the owner (PasskeyAddedNotification), queued with beforeCommit() in the
same transaction as the passkey, so a queue failure keeps no passkey.passkeyId, not passkey: the package binds {passkey} globally to any user's passkey.SocialiteService::disconnectProvider(), and
nothing stops a user removing their last passkey; forgot-password is the way back in.passkeys table migration is shipped by this module (the package only publishes one).APP_URL) and need HTTPS or
localhost. Changing the domain invalidates every passkey.Adding or removing a passkey, and changing the email, need fresh proof: Laravel's
password.confirm and auth.password_timeout, the same auth.password_confirmed_at session key.
ConfirmIdentityDialog (Vue and React) takes the password (password.confirm.store) or a
passkey (the package's PasskeyConfirmationController). It posts over JSON through core's
useHttp, never as an Inertia visit: a visit reloads the panel and overwrites the passkey
challenge in the session.password_confirmed_until and password_timeout (PasswordConfirmation::props()),
never a boolean, and ask again 30s early (lib/passwordConfirmation.ts). An email change is
checked in ProfileController, since a route middleware can't tell it from a name change.confirm_identity comes back as a validation error (email
change, and ConfirmPasswordController::show() for a stopped Inertia visit), and the panel
reopens the dialog and retries. A failed add only marks the session unconfirmed: the
passkeys client keeps no status code.ForgetPasswordConfirmation drops the confirmation on entering and leaving impersonation,
so one account's password never vouches for another.LoginRequest::ensureIsNotRateLimited() — 5 attempts per email|ip key. Fires Lockout event, throws AuthException::throttle($seconds). Cleared on success. Covered in PHP (LoginTest), not e2e: Playwright workers share one IP, so a lockout there leaks into other login tests.
manage users opens the Users page (UserResource::canAccess()); impersonate users is separate. Both come from Database\Seeders\DatabaseSeeder, granted to nobody. AuthenticationSettings is a site-wide settings page (manage settings, from core's SettingsPage).
Staff with manage users can never reach an admin, and one admin always remains:
UserPolicy refuses update, delete and impersonate on an admin. admin passes every check through Gate::before, so it binds only non-admins.user (CreateUser::afterCreate()).UserResource::canDelete()), which the delete button and each row of the bulk delete both ask.can('impersonate', $target): the Filament action (Impersonate::configureUsing() in the provider), ReimpersonateController, and tenancy's handoff.Uses stechstudio/filament-impersonate. Session stores history at impersonation.recent_history (max 4 user IDs). ReimpersonateController lets admins re-impersonate from recent list (max 3 shown in UI, filters deleted users and self). Stop via filament-impersonate.leave route.
MagicLinkController::store() silently finds the user by email (no error on unknown email). If found: deletes existing tokens for that user, creates a new MagicLinkToken (token = SHA-256 hash of Str::random(64), expiry controlled by AuthSettings), and sends MagicLinkNotification with the plain-token URL.
MagicLinkController::authenticate() hashes the incoming token and consumes it in one atomic UPDATE (unused and unexpired only), so a link works once even under concurrent clicks, then logs in the user and redirects to the intended URL or home.
AuthSettings is auto-discovered from src/Settings, with defaults installed
from database/settings. Magic links are disabled by default, with a 15-minute
expiry once switched on. Administrators manage both values through the AuthenticationSettings
Filament page under the Settings navigation group.
Token storage: Plain token only lives in the email link. DB stores hash('sha256', $plainToken). This means even if the DB is compromised, tokens cannot be forged or replayed.
settings.* NamesProfile management moved here from the retired settings module. The routes deliberately
keep the /settings/* URLs and settings.* names, because app core resolves them by name,
and PasswordChangedNotification links to route('settings.profile'). That route now
redirects into the #settings/profile fragment rather than rendering a page.
The settings shell is core's modal (pages/Settings, useSettingsModal); this module
only contributes ProfileSection and SecuritySection. Core discovers them from
src/Settings, so NavUser no longer guards on route().has('settings.profile') — it
guards on whether any section was contributed at all.
SettingsProfile holds name, email, and avatar. SettingsSecurity holds the password form
and connected social accounts. The split is by the question being asked, not by ownership:
both sections live here, and SecuritySection sits at order 15 so it follows Profile (10)
ahead of anything another module contributes.
Both URLs stay canonical pages. LoginController::create() and RegisterController::create()
return Inertia::render() unless both AuthSettings::$modal_enabled is on and the request
carries Modal::HEADER_MODAL; only then do they return Inertia::modal() with a modal: true
prop. There are no modal-only components — the page components branch on that prop, and the
form closes the modal through useModal() on success.
Branching on the header explicitly matters: Modal::toResponse() falls back to the referer
when the header is absent, which would turn any ordinary in-app link to /auth/login into a
modal over the previous page.
The modal's base-URL header counts as asking too. A form in the modal that fails
validation is redirected back to the modal's own URL, and the browser follows that with
the base-URL header but not the modal header; without it the modal came back as the full
page. The client sends that header only while a modal is open. Login errors are
validation errors on email for the same reason: a flashed error lands on the page
behind the modal, where nothing shows it.
AuthLink swaps sibling screens by closing the modal and opening the next one only after
the close has navigated back to the page behind. Opened sooner, the sibling records the
closing modal's URL as its base and is torn down by that navigation. A second click while
a swap is in flight is ignored.
AuthServiceProvider shares the setting as auth.modal_enabled, and core's Header
reads it to swap Link for ModalLink on the navbar Sign In and Get Started
entries — ModalLink is what sends the header. Every other link to those URLs stays a page.
PasswordController::update() is shared by two routes: password.update (PUT /auth/password)
and settings.profile.password.update. It redirects to settings.profile on success.
The module's resources/js/app entry registers the logout, settings, and profile icons plus the logout action handler via registerIcon() and registerAction() from @saucebase/vue/lib/navigation (or @saucebase/react/...). The action shows a confirmation dialog and posts to route('logout').
php artisan auth:make-admin {email?} # promote an existing user to admin
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_CLIENT_REDIRECT_URI
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_CLIENT_REDIRECT_URI
Redirect URIs default to /auth/socialite/{provider}/callback. Providers configured in config/services.php.
php artisan test --testsuite=Modules --filter='^Modules\\Auth\\Tests' # PHPUnit
npx playwright test --project="@auth*" # E2E
E2E coverage: login (basic, errors, security (CSRF), social, modal, logout), register (basic, errors), forgot-password (basic, errors), verify-email, profile (avatar, socialite settings under the Security panel), passkeys (add, sign in, remove, each confirmed — Chromium's virtual authenticator, so Chromium only), confirm-identity (email change). Page objects in tests/e2e/pages/; specs import the shared test fixture from @e2e/fixtures.
LoginRequest::validateCredentials() validates without logging in — the controller handles Auth::login() separatelyRegisterRequest::passedValidation() hashes the password before the controller sees itmodal_enabled is presentation only. Turning it off does not change what the routes do, only how they are reached — the canonical URLs work either way, which is why there is no separate modal route.registration_enabled closes both signup paths: EnsureRegistrationEnabled 404s the register routes, and SocialiteService::handleCallback() throws registrationDisabled() rather than creating a new user (existing users still sign in). Login is deliberately not toggleable — disabling it would lock out admins.How can I help you explore Laravel packages today?