# Authentication & Authorisation

Creatia handles authentication and authorisation in two separate ways:
1. **Frontend / Human Driven**: This method is used whenever authenticating using the web application. It is backed by **Laravel Sanctum** - This plane handles obvious authentication functionality, but also handles password resets, websocket leases, and alert issuance. When you've authenticated with this method, authorisation is handled by the bespoke `Bouncer` middleware.
2. **API / Machine Driven**: This method is used whenever something needs to interface with Creatia in an automated manner to achieve something. We use a custom setup for this as not only did this codebase start before we used Sanctum, but we also want the API to be handled on an organisational basis, not a user basis. It works by using bearer tokens as shown by `ApiToken`, `ApiTokenBearer` - It also makes use of the `Security` helper. The `BearerController` handles issuance of these tokens when it is provided a request with valid secret and identity tokens.
<br><br>
We also using caching when checking the validity of a bearer token to keep things fast.
<br><br>
Authorisation in this plane is handled by the bespoke `Token` middleware.

With this in mind, this document will cover the specifics for both sets of authentication and authorisation.

## Frontend / Human Driven Authentication

The guard wiring lives in `config/auth.php` - The default guard is `api`, driver `sanctum`, provider `users` (our `User` model). So whenever you see `auth:api` on a route, it means "a valid Sanctum token that resolves to a User". Token lifetime comes from `config/sanctum.php` (`expiration = 4320` minutes, i.e. three days). The `SANCTUM_STATEFUL_DOMAINS` and SPA-cookie machinery all exist in config, but the stateful `EnsureFrontendRequestsAreStateful` middleware is commented out in `app/Http/Kernel.php` - We run Sanctum purely as tokens-in-the-header, not first-party cookies.

Everything in this plane lives in `app/Http/Controllers/Auth/AuthController.php`. Its constructor applies `auth:api` to everything **except** `login` and `reset` - The two things a logged-out user obviously has to be able to reach.

### Login - `POST auth/login`

- **Status**: LIVE (`routes/api/authentication/Account.php`, guest group).
- **Fields**: `email`, `password` (both required), `code` (the six-digit TOTP code - only relevant when the account has MFA enabled, see the MFA section below).
- **What happens**, in order:
  1. **Per-account lockout** (`check_excessive_account_attempts`) - Counts `failed_logins` for the user in the last `security.account_lockout_time` minutes (default 5); at or above `security.account_login_attempts` (default 10) we return a 429 immediately. This one is aimed at a targeted attack against a single account.
  2. **Per-IP rate limit** (`RateLimiter::attempt("login-attempt:{ip}", security.client_login_attempts)`, default 10/min - raised from 5 when MFA landed, because a full two-leg MFA login costs two attempts) - Aimed instead at broad brute-force / credential-stuffing from one source. When it trips we return a 429 with a seconds-until-retry message.
  3. **Credential check** - We resolve the user case-insensitively (`whereRaw('lower(email) = ?')`) and `Hash::check` the password. On failure we log a `failed_logins` row and return a 401.
  4. **MFA gate** (only when the user has a confirmed enrollment) - If `Security::mfa_bypassed()` says the request comes from a configured internal block, we log a `security_events` "MFA bypassed - internal network" row and skip the challenge. Otherwise the `code` field has to verify against the user's TOTP secret (`verifyKeyNewer`, so a code can never be accepted twice - The accepted timeslice is persisted to `users.totp_last_slice`). A missing code returns 401 `MFA code required` **without** a `failed_logins` row (it's just the first leg of the two-step flow); a wrong code returns the identical body but does log a `failed_logins` row plus a "Failed MFA code" `security_events` row, so grinding the six-digit space runs into the account lockout.
  5. On success we mint a Sanctum token (`createToken('api', ['*'], $expiresAt)`) and hand back the id/email/name/organisation, the token and its expiry, the user's resolved permissions, an `mfa` boolean (whether the account has a confirmed enrollment - `me()` returns it too, so the frontend always knows), whether the org has any ASNs, and any alerts.
  6. The whole method sits inside a `try/catch(\Throwable)` that returns a generic 500 - We never leak a stack trace to the client.
- **Why two layers of limiting**: the two counters guard genuinely different attacks (there's a comment in `config/security.php` spelling this out) - The IP counter stops a noisy attacker spraying many accounts, and the account counter stops a patient attacker grinding one account from rotating IPs.
- **Watch out for**: an earlier version did a standard `Auth::attempt` and only fell back to a lowercased lookup - The current code goes straight to the lowercased lookup as the primary path (the inline comment says as much). The real problem underneath is that emails aren't normalised on insert, so the lowercased query is a workaround - The proper fix is a one-shot normalise-on-write migration. Until that happens, don't "optimise" the `whereRaw` back into a plain `where('email', ...)` or case-varying duplicates could end up logging in inconsistently. The frontend also still doesn't handle the 429 gracefully - Low priority, but real.

### Token refresh - `GET auth/refresh`

- **Status**: LIVE (auth:api group). Deletes the caller's current token and issues a fresh one (same three-day expiry), stamping `users.refreshed_at`.
- **Watch out for**: this has to be called **before** the token expires - An expired token can't authenticate well enough to reach the route in the first place. There's a known, harmless bug here too: if the token has already expired, `$_request->user()` is null and touching a property on it throws, which the `try/catch` swallows into a 400. It does no damage, but the clean fix is to null-check the user first - The same latent pattern lives in `me()`.

### Logout - `GET auth/logout`

- **Status**: LIVE (auth:api). Writes a `security_events` "Successful logout" row, then deletes the current access token.
- **Watch out for**: it doesn't clear the user's `event_lease` rows - A logged-out user's websocket leases just linger until they age out. The two-minute freshness window makes this cosmetic, but it's still worth tidying up eventually.

### Password reset request - `POST auth/reset-password`

- **Status**: LIVE (guest group, `throttle:3,60`). If the email matches a user we send a reset email, but we **always return 200 regardless** so nobody can enumerate which emails exist. Route-throttled to three per hour.

### Websocket lease - `GET auth/lease`

- **Status**: LIVE (auth:api). Field: `resource` ∈ {`weathermap`, `droppedTraffic`, `mercury`}. Creates or refreshes an `event_lease` row (`leased_at = now`) for the calling user. This is the heartbeat our broadcast automation reads - A lease fresher than two minutes means "someone is actively watching", which is how we decide whether it's worth emitting the expensive websocket events (see the Broadcast section of the automation docs). Anything outside the enum is a 400.

### Current user - `ANY auth/information` (`me()`)

- **Status**: LIVE (auth:api). Returns the same profile shape as a successful login (minus the token), stamps `refreshed_at`, and serves alerts. Carries the same latent null-user bug as `refresh`.

### Alerts - `helper_retrieve_alerts()` and `ANY auth/alert/acknowledge`

Alerts are the frontend's pop-up / banner system, served on login and on `me()`. There are two kinds:
- **Unassociated** (`audience LIKE 'first-time-%'`) - Shown once to everyone, and suppressed per-user once dismissed (an `alert_displays` row with `dismissed = 1`).
- **Associated** - Targeted, and require a pre-existing `alert_displays` row per user.

Both honour their `live_at`/`down_at` windows and are keyed by `audience` in the response. `acknowledge_alert` marks an alert dismissed for the caller (creating the `alert_displays` row if it doesn't exist yet) and - because some of these alerts are legal-agreement acknowledgements - fires a Slack notification to `HOOK_GOLIATH` recording who accepted what.

### Multi-factor authentication (TOTP) - `MfaController.php`

- **Status**: LIVE (auth:api group, `auth/mfa/*`). Opt-in per user; the second factor is a standard authenticator-app TOTP code (RFC 6238 via `pragmarx/google2fa`).
- **State model** - Three columns on `users`: `totp_secret` (encrypted at rest via the custom `App\Casts\MfaEncrypted` cast under a **dedicated key derived from a plain-text passphrase** (`MFA_ENCRYPTION_SECRET`, stretched through PBKDF2-SHA256) rather than `APP_KEY` - so application-key rotation never invalidates enrollments, and a leaked `APP_KEY` alone doesn't expose second factors; hidden on the model, deliberately **not** fillable), `totp_confirmed_at` (a set secret with a null confirmation is an enrollment still in progress - Only a confirmed enrollment enforces the challenge at login, mirroring the `suspended_at`-style timestamp-as-state convention), and `totp_last_slice` (the last accepted 30-second timeslice, which is what makes codes single-use).
- **Routes**:
  - `GET auth/mfa` (`status`) → `{ enabled, pending }`.
  - `POST auth/mfa/setup` → generates a fresh secret and returns `{ secret, otpauth_uri }` - The frontend renders the URI as a QR and shows the secret for manual entry. Re-running setup while still pending just regenerates; running it on an already-enabled account is refused (the factor has to be proven via disable first, so a stolen bearer token can't rotate the secret out from under the user).
  - `POST auth/mfa/confirm` (`throttle:6,1`) - Field `code`. The first valid code flips `totp_confirmed_at` and enforcement begins.
  - `POST auth/mfa/disable` (`throttle:6,1`) - Field `code`. Requires a **current TOTP code**, not the password - A hijacked bearer token alone must not be enough to strip the factor. It's a POST rather than a DELETE because some clients strip DELETE bodies.
- **Internal bypass** - `Security::mfa_bypassed()` checks the request against `security.mfa_bypass_blocks` (`MFA_BYPASS_BLOCKS`, comma-separated CIDRs). Logins from a matching block skip the challenge entirely (and log a `security_events` row saying so). It compares **both** `request()->ip()` and `real_ip()` against the list, because `real_ip()` filters private ranges out (a VPN client at `10.x.x.x` only ever matches via `request()->ip()`), while behind the load balancer only `real_ip()` sees a public corporate egress address. **Entries must be ranges wider than a single host** (`/31` or wider for IPv4, `/127` or wider for IPv6) - Matching delegates to `UtilPrefix::ip_in_block()`, which skips any block whose mask isn't wider than the candidate's own `/32`//`/128`, so bare-IP and `/32`//`/128` entries silently never match.
- **Admin reset** - Two planes, mirroring the usual Owned*/AdminAny* split. `DELETE user/{id}/mfa` (`bounce:organisation.manage`, `User\Controller::reset_user_mfa`, org-scoped via `OwnedUserIdentity`) lets an organisation manager clear a same-org user's enrollment, and Slack-notifies `HOOK_STANDARD`. `DELETE admin/user/{id}/mfa` (`bounce:management.write`, `User\AdminController::reset_user_mfa`, any user via `AdminAnyUserIdentity`) is the system-administrator version with no organisation scoping. Both are the lost-device recovery path - There are deliberately **no recovery codes**; a locked-out user logs in from an internal range, or gets reset by their org manager or a system admin. Both write a `security_events` row against the acting user with the target in the payload.
- **Drift** - `security.mfa_totp_window` (`MFA_TOTP_WINDOW`, default 1) tolerates that many 30-second steps of clock skew either side of now.
- **Watch out for**:
  - A *public* CIDR in `MFA_BYPASS_BLOCKS` is spoofable by anyone who learns it, because `real_ip()` trusts forwarded headers. Private/VPN ranges (matched via `request()->ip()`) are not. Keep the list to internal ranges.
  - The 401 `MFA code required` response only appears after a **correct** password, so it confirms password validity to whoever sees it. That's the accepted trade-off of the single-endpoint resubmit flow (versus a challenge-token flow); the per-IP and per-account limiters bound the damage.
  - A valid bearer token *can* enroll MFA on an account that doesn't have it yet (setup/confirm don't re-prompt the password). Unavoidable without a password re-prompt, and the SecurityEvent trail records it.
  - A full MFA login costs **two** of the ten per-minute per-IP login attempts (challenge leg plus code leg) - The limit was raised from five to ten for exactly this reason, but it's still worth remembering if the frontend ever auto-retries.
- **Where enrollment state surfaces** - The login and `me()` payloads carry an `mfa` boolean for the calling user, and both user lists (`GET user` for org managers, `GET admin/user` for system admins) include a per-row `mfa` flag (a CASE on `totp_confirmed_at`, same pattern as `is_setup`) so the reset buttons know when to show.

### Account setup & password-reset tokens - `TokenController.php`

- **Status**: LIVE (guest-only). This controller handles the email-token flows: initial account setup and password resets.
- **Routes**: `GET auth/{type}/{token}` (`check` - is this token valid?), `POST auth/setup/{token}` (`setup` - set the initial password), `POST auth/reset/{token}` (`reset` - set a new password).
- **Two kinds of token, stored differently** (worth knowing):
  - **Signup tokens** (`users.signup_token`) are stored and matched **in plaintext**.
  - **Reset tokens** (`code_user_resets.token`) are stored **sha256-hashed** with a two-hour `expiry_at` (minted by `User::sendResetEmail()`, which loops until it has a unique one). A successful reset consumes the token and clears all of the user's reset rows.
- **Password policy** (`valid_password`, shared by setup and reset): at least 8 characters, with at least one lowercase, uppercase, digit and special character.
- **Watch out for**: the plaintext-vs-hashed asymmetry means a database leak hands over usable signup tokens directly - They're single-use and cleared on setup, but it's still worth hashing them the next time this area gets touched.

## API / Machine Driven

This is our own token system, and it has nothing to do with Sanctum. It exists so appliances and integrations can authenticate without a user account, with per-IP whitelisting and per-token scopes. The moving parts:

- **`ApiToken`** (`api_tokens`) - A long-lived credential pair: `identifier` (public) plus `token` (secret, **bcrypt-hashed** via `Hash`), owned by an organisation. Soft-deleted, and uses the `Owned` trait.
- **`ApiTokenWhitelist`** (`api_token_whitelists`) - The source IPs allowed to use a token. The literal `0.0.0.0` is a **wildcard meaning "any IP"**.
- **`ApiTokenBearer`** (`api_token_bearer`) - The short-lived (600s) bearer strings minted from a token; pruned an hour after they expire.
- **`ApiTokenPermission`** + `api_scopes` - The scopes a token holds. This is the machine-plane equivalent of a group's permissions.

### Issue a bearer - `POST auth/issue` (`BearerController::issue`)

- **Status**: LIVE (`api` middleware only).
- **How it works**: `Security::valid_token_information()` checks the `identifier`, `Hash::check`s the secret `token`, and confirms the caller's IP is whitelisted for that token (or that `0.0.0.0` is present). On success we mint a random 32-character bearer with a **600-second** expiry and return it. On failure we log a debug line with the source IP and the offending header, and return a 401.
- **Why so short-lived**: the bearer is what travels on every subsequent request, so keeping it to ten minutes keeps the brute-force / replay window small - Same reasoning behind having a `revoke` at all.

### Use a bearer - the `token` middleware (`app/Http/Middleware/Token.php`)

This is applied as `token` or `token:<scope>,<scope>...` on machine routes (the Goliath agent routes, for instance, are `token:goliath`). It hands off to `Security::valid_bearer_token($request, $scopes)` and switches on the result:

- **valid** → writes an `api_events` row (`"Method: GET"` and so on - This is exactly the heartbeat `goliath:monitor-agent` reads to spot live appliances) and lets the request through.
- **`INACTIVE`** (token soft-deleted / gone) or **`EXPIRED`** (bearer past its 600s) → logs an `api_events` row describing the attempt and returns a 401.
- **`INVALID`** (no bearer, or a garbled one) → 401, no event row.

`Security::valid_bearer_token()` is the heart of the machine plane, and it's heavily **Redis-cached** (every key 300s, and the bearer-resolution key is additionally capped to the bearer's own remaining lifetime so a cached entry can never outlive the bearer it represents):

1. `api-bearer-{bearer}` → resolves the bearer to a `token_id` (DB lookup on a miss, `expiration_at > now()`).
2. `api-whitelist-{token_id}:{ip}` → is this IP whitelisted for the token.
3. `api-token-{token_id}` → is the token still active (i.e. does it still exist).
4. `api-perms-{token_id}-{md5(sorted scopes)}` → does the token hold **all** the requested scopes (a count-match against `api_token_permissions` ⋈ `api_scopes`).

Only if every applicable check passes do we get back `[bearer, token_id, true]`. An empty scope list means authentication-only - Any valid token passes.

- Note that **both `Authorization` and `Authorisation` (the British spelling) are accepted** - That's a deliberate compatibility affordance, so don't "fix" one of them away.
- **Watch out for**: revocation is only immediate because `BearerController::revoke` explicitly `Redis::del`s the `api-bearer-{bearer}` key - The `api-whitelist-*`, `api-token-*` and `api-perms-*` caches are **not** busted on revoke, so a token soft-deleted mid-session stays "active" in cache for up to 300s (the `api-token-{token_id}` key). That's the blast radius for a compromised-but-revoked token. This function also logs full request headers at debug on every failure path - Handy in development, noisy and a little sensitive in production.

### A quick note on the duplicate in `AuthServiceProvider`

`app/Providers/AuthServiceProvider.php` registers `Auth::viaRequest('token', ...)` - a custom guard driver whose body is a near-verbatim copy of the `Token` middleware's switch (same `Security::valid_bearer_token` call, same `INACTIVE`/`EXPIRED`/default handling, same `ApiEvent` logging), except it returns an `ApiToken` model rather than passing the request down the pipeline. **It is dead code** - No guard in `config/auth.php` uses the `token` driver, and nothing calls `auth:token` / `Auth::guard('token')`, so it never runs. It also carries a bug that confirms it isn't exercised: the closure references `$_perms ?? []` but `$_perms` is never a parameter, so it would resolve tokens with *no* scope enforcement. The live machine-auth path is the route middleware above - This copy is worth deleting (or the shared switch worth factoring into `Security`) the next time the area is touched.

### Revoke a bearer - `POST auth/revoke` (`BearerController::revoke`)

- **Status**: LIVE (`token` middleware). Validates the bearer, deletes the `ApiTokenBearer` row, and drops the `api-bearer-{bearer}` Redis key so the revocation bites on the very next request. Returns a 401 if the bearer wasn't valid to start with.

## Authorisation - What You're Allowed To Do

### Human plane - groups, permissions, and the Bouncer

A `User` belongs to a `group_id`, and `User::getPermissions()` works out what that group can actually do:

- Start from the `group_permissions` for the user's group.
- Drop any permission with an `identifier` set unless the org holds a matching `organisation_external_identities` key - This is how we gate brand/integration-specific features per organisation (something WHMCS-linked, for instance).
- Drop any permission with a `conditional` set unless the org holds the matching **product right** (`product_rights`) - This is how paid features like Mercury light up.
- Only `status == 1` permissions survive.

`User::canDo($perms)` returns true only when the user holds **every** permission in the list (a logical AND; a null or empty entry fails closed). The group ids themselves are seeded via `config/security.php` (`group_admin_id = 1`, and so on).

The **Bouncer middleware** (`app/Http/Middleware/Bouncer.php`, alias `bounce`) is where this gets enforced on human routes, always paired with `auth:api` - e.g. `['auth:api', 'bounce:network.redirect-view']`. Note that **comma-separated scopes are ANDed** - `bounce:management.view,management.write` needs the user to hold *both*. What it does:

- Logs every hit (`Log::info`) with the user, IP, path, agent and method.
- If `canDo` passes: for non-GET requests it writes a `security_events` audit row (with password fields obscured), stamps `refreshed_at`, and continues. If `canDo` fails: it writes a failed-attempt `security_event` (with a null user_id), fires a Slack alert to `HOOK_STANDARD`, and returns a 401.
- **Demonstration short-circuit**: when `APP_DEMONSTRATION` is on, non-GET requests never reach the controller - Bouncer returns the controller's `demoResponses[$method]` if it's defined, otherwise a generic success. This is how the demo environment happily accepts writes without ever mutating anything.
- **Watch out for**: the account-suspension check (`canLogin()` → force a logout) is **commented out**, so suspended accounts aren't actually blocked at this layer right now. If suspension ever matters, that block needs reinstating (and `canLogin` needs to actually exist - It currently doesn't). There's also a missing space in the failed-attempt log message (`"...requiring: Xusing method: Y"`) - Purely cosmetic.

### Machine plane - token scopes

Machines don't touch groups or the Bouncer. Their authorisation is the scope check baked into `token:<scope>` (step 4 above) - A token has to hold every requested `api_scopes`. That's the whole machine-side authorisation model, so keep scope grants tight, because there's no second gate behind it.

### Websocket channel authorisation - `routes/channels.php`

Broadcast auth runs under `auth:api` (`Broadcast::routes(['middleware' => 'auth:api', 'prefix' => 'api'])`), so subscribing to a channel uses the **human** plane. The channels:

- `weathermap` - Any authenticated user.
- `admin-inline-rib` - Org 1 **and** `goliath.l1.manage` (admin-only).
- `goliathTraffic.{organisation_id}` - A member of that org **and** `goliath.l1.chart`.
- `goliathAdminTraffic` - `goliath.l1.manage` **and** `goliath.l1.chart`.
- `mercury.{organisation_id}` - A member of that org **and** `mercury.v2.view`.

Each one re-loads the user with `User::find($user->id)` before calling `canDo` - Mildly wasteful, but harmless. These gates have to stay in step with whatever the broadcast commands actually emit (see the automation docs).

## Perimeter Middleware (Adjacent To Authentication)

### `whitelist:<list>` - `app/Http/Middleware/Whitelist.php`

A straight IP allowlist keyed by config - It passes only if `Security::real_ip()` appears in `config("creatia.whitelist.{list}")`. Non-GET passes write a `security_events` row (as user 1, "Ignore UID"), and a blocked request fires a Slack alert to `HOOK_STANDARD` and returns a deliberately odd **418**. We use this to fence off internal / trusted-source endpoints.

### `whmcs` - `app/Http/Middleware/Whmcs.php`

The WHMCS integration gate - First an IP whitelist (`creatia.whitelist.whmcs`), then an **HMAC-SHA256 integrity check**: the `X-Signature` header has to equal `hash_hmac('sha256', http_build_query(ksort'd payload), WHMCS_SECRET)`. All three outcomes (success, integrity failure, non-whitelisted) write `security_events` rows as user 1, and the two failures also Slack-alert `HOOK_STANDARD`.

- **Watch out for**: the environment doc notes that `WHMCS_SECRET` was *"Disabled at this time in favour of hard white-listing since people kept changing things without ever telling me."* The middleware still computes and compares the HMAC, so the real security here depends entirely on what `WHMCS_SECRET` actually is in the live `.env` - If it's blank, the HMAC degrades to a constant and the **IP whitelist is the only real gate**. Confirm the deployed secret before relying on integrity checking, and reconcile the code with the env-doc intent, because the two currently disagree. There's also a stale comment ("Try both varieties of sorting...") - Only one `ksort` actually runs.

## Supporting Pieces

### `Security::real_ip()` (`app/Helpers/Security.php`)

Every auth and audit decision runs through this rather than Laravel's `$request->ip()` directly. It walks a list of forwarded-for headers (`HTTP_CLIENT_IP`, `HTTP_X_FORWARDED_FOR`, and so on) and returns the first value that's a **valid public IP** (`FILTER_FLAG_NO_PRIV_RANGE | NO_RES_RANGE`), falling back to `request()->ip()` if it finds nothing. This matters because we sit behind proxies, and our rate-limiting, whitelisting and lockout all key off this value.

- **Watch out for**: header-based client IPs are spoofable if the edge proxy doesn't strip or normalise `X-Forwarded-For`. The public-range filter helps (an attacker can't claim to be a private IP), but per-IP lockout and whitelist trust still ride on the proxy layer being sane - So keep `TrustProxies` (the global middleware that decides which proxies Laravel trusts) aligned, and document the proxy config alongside this.

### Audit tables

Auth activity is spread across a few tables, all keyed on `real_ip()`:

- **`failed_logins`** - Failed human logins (this is what feeds the account lockout).
- **`security_events`** - Human-plane successes and failures via Bouncer / Whitelist / Whmcs, plus explicit logout. Passwords in the payload are replaced with `"OBSCURED FOR SECURITY"`.
- **`api_events`** - Machine-plane requests via the `token` middleware (and the appliance heartbeat `goliath:monitor-agent` reads).

### Response / websocket obfuscation

The `obsfucation.*` toggles in `config/security.php` (`SymObsfucate`) can wrap websocket payloads (and responses) in a salt / noise / flip transform when they're enabled. It's off by default, and it's an obscurity layer rather than encryption - It's orthogonal to authentication and only mentioned here because it lives in the security config.

## Consolidated "Watch Out For"

- **It's Sanctum, not JWT** - Ignore the leftover `JWT_SECRET` and any lingering "JWT" references (the old controller README that claimed JWT has been replaced with a pointer to this doc).
- **Emails aren't normalised on write** - Login leans on a lowercased query as a workaround; fix it with a migration, not by removing the workaround.
- **Bearer revocation only busts one of four Redis caches** - A revoked or soft-deleted token can survive in the `api-token` / `api-whitelist` / `api-perms` caches for up to 300s.
- **Account suspension isn't enforced** - The `canLogin()` block in Bouncer is commented out.
- **The `AuthServiceProvider` `token` guard is dead code** - A near-duplicate of the `token` middleware that nothing wires up; delete it or factor the shared switch into `Security`.
- **WHMCS integrity depends on the deployed `WHMCS_SECRET`** - The code does the HMAC, the env doc says it was disabled in favour of IP whitelisting; verify which is actually true in production. WHMCS is largely being migrated over to using tokens as well.
- **Client IP trust is only as good as the proxy layer** - `real_ip()` reads forwarded headers, so lockout and whitelist security ride on `TrustProxies` being correct.
- **Latent null-user throws** in `refresh()` and `me()` on expired tokens (caught, harmless).
- **Signup tokens are stored in plaintext** while reset tokens are hashed - An asymmetry worth closing. This isn't a serious concern.
- **`bounce:a,b` means AND** (every permission required), not OR - Important when you're composing route guards.
- **MFA bypass blocks must stay internal** - A public CIDR in `MFA_BYPASS_BLOCKS` is spoofable via forwarded headers; private/VPN ranges are not. And remember there are no recovery codes - Lost devices go via the internal network or the org-manager reset.
