Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
saucebase-auth-development
saucebase-auth-development
saucebase/auth
claude-code
auth
magic-link
oauth
email-verification
password-reset
impersonation
filament

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.

Install
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.

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

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

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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource