Files
crewli/dev-docs/AUTH_ARCHITECTURE.md
bert.hausmans a9ef384515 fix: prevent cross-app auth session sharing on localhost
Root cause: browsers don't scope cookies by port. With SESSION_DOMAIN=
localhost, all three SPAs share cookies. The CookieBearerToken middleware
iterated all cookie names and picked the first match, so logging into
the organizer app (port 5174) also authenticated the portal (port 5175).

Fix: CookieBearerToken now resolves the correct cookie name from the
Origin header (same logic as SetAuthCookie trait). It only reads the
cookie matching the requesting app — portal origin reads only
crewli_portal_token, app origin reads only crewli_app_token, etc.

Falls back to first-available cookie when no Origin header is present
(server-to-server requests, tests without explicit Origin).

Added 3 cross-app isolation tests:
- app cookie does NOT authenticate portal requests
- portal cookie does NOT authenticate app requests
- correct cookie + matching origin = authenticated

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:19:42 +02:00

177 lines
7.7 KiB
Markdown

# Crewli — Authentication Architecture
> Version: 1.0 — April 2026
> Audience: security auditors, backend developers
---
## 1. Authentication Overview
Crewli uses **stateless token-based authentication** via Laravel Sanctum. Three SPA clients communicate with a single REST API. Tokens are stored exclusively in **httpOnly cookies** set by the server — they are never exposed to JavaScript via response bodies, localStorage, or JS-readable cookies.
### Client Applications
| App | URL (dev) | URL (prod) | Purpose |
|-----|-----------|------------|---------|
| Admin | localhost:5173 | admin.crewli.app | Super admin / platform management |
| App | localhost:5174 | app.crewli.app | Organiser dashboard |
| Portal | localhost:5175 | portal.crewli.app | Volunteers, artists, suppliers |
### Access Modes
The Portal supports two access modes:
1. **Cookie-based** (`auth:sanctum`): volunteers and crew who have a `user_id` — login with email/password, httpOnly cookie set on login
2. **Token-based** (`portal.token` middleware): artists, suppliers, press — stateless per-request token via `Authorization: Bearer` header or `?token=` query parameter. No cookies involved.
---
## 2. Cookie Specification
| App | Cookie Name | Domain | Secure | httpOnly | SameSite | Max-Age |
|-----|-------------|--------|--------|----------|----------|---------|
| Admin | `crewli_admin_token` | `.crewli.app` (prod) / `localhost` (dev) | Yes (prod) | Yes | Strict | 7 days |
| App | `crewli_app_token` | `.crewli.app` (prod) / `localhost` (dev) | Yes (prod) | Yes | Strict | 7 days |
| Portal | `crewli_portal_token` | `.crewli.app` (prod) / `localhost` (dev) | Yes (prod) | Yes | Strict | 7 days |
Each SPA gets its own cookie name to prevent shared auth state between apps. The cookie domain is configured via `SESSION_DOMAIN` in `.env`.
---
## 3. Token Lifecycle
### Creation
On successful login (`POST /auth/login`), the server:
1. Validates credentials via `Auth::attempt()`
2. Creates a Sanctum personal access token
3. Resolves the cookie name from the `Origin` header
4. Returns user data in the JSON body (no token in body)
5. Attaches the token as a `Set-Cookie` header with httpOnly flag
### Validation
The `CookieBearerToken` middleware (registered before `auth:sanctum` in the API middleware stack):
1. Reads the `Origin` (or `Referer`) header to identify which app is making the request
2. Resolves the correct cookie name for that app (e.g. portal origin → `crewli_portal_token`)
3. Reads only that cookie and sets `Authorization: Bearer` on the request
4. Sanctum's existing token validation processes the header normally
**Cross-app isolation:** In local development, all three SPAs share `localhost` (different ports). Browsers do not scope cookies by port, so all three app cookies are sent with every API request. The middleware prevents cross-app authentication by only reading the cookie that matches the requesting app's Origin header. Without this, logging into one app would authenticate all apps.
If the `Origin` header is absent (e.g. server-to-server requests), the middleware falls back to the first available cookie. If an `Authorization` header is already present (e.g. from the portal token flow), the middleware skips cookie injection entirely.
### Rotation
`POST /auth/refresh` (authenticated endpoint):
1. Revokes the current access token
2. Creates a new token
3. Returns user data with a new httpOnly cookie
4. Logs the refresh event
Clients should call this endpoint periodically (recommended: every 24 hours) to rotate tokens.
### Expiration
Tokens expire after **7 days** (configured in `config/sanctum.php`). After expiration, Sanctum rejects the token and the client receives a 401. The cookie's `Max-Age` matches the token expiration.
### Revocation
Tokens are revoked on:
- **Logout** (`POST /auth/logout`): current token deleted, cookie expired
- **Password reset**: all user tokens revoked
- **Password change**: other session tokens revoked
- **Email change verification**: all user sessions revoked
- **Token refresh**: old token replaced with new one
---
## 4. CSRF Protection
**CSRF tokens are not required.** The `SameSite=Strict` cookie attribute prevents the browser from sending the auth cookie on cross-origin requests. This means:
- A malicious site cannot forge authenticated requests because the cookie is never attached to cross-origin submissions
- `SameSite=Strict` is stricter than `Lax` — even top-level navigations from other sites will not include the cookie
Reference: [OWASP CSRF Prevention Cheat Sheet — SameSite Cookie Attribute](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#samesite-cookie-attribute)
---
## 5. Attack Surface Analysis
### XSS — Token Theft
**Mitigated.** The bearer token is stored in an `httpOnly` cookie and is never present in:
- The JSON response body
- `localStorage` or `sessionStorage`
- JS-readable cookies (`document.cookie`)
Even if an XSS vulnerability exists, the attacker cannot read the token. They can make authenticated requests from the user's browser session, but cannot exfiltrate the token for use elsewhere.
### CSRF — Cross-Site Request Forgery
**Mitigated.** `SameSite=Strict` prevents the browser from attaching the cookie to any request originating from a different site, including form submissions and top-level navigations.
### Network Interception — Token Theft
**Mitigated in production.** The `Secure` flag ensures the cookie is only sent over HTTPS connections. In development (localhost), `Secure` is disabled to allow HTTP.
### Server Compromise — Token Theft
**Partially mitigated.** Sanctum hashes tokens in the `personal_access_tokens` table using SHA-256. An attacker with database read access sees hashed tokens, not plaintext values. However, an attacker with full server access could intercept tokens in memory.
### Token Fixation
**Not applicable.** Tokens are generated server-side using cryptographically secure random values. The client never provides or influences the token value.
---
## 6. Portal Token-Based Flow (Artists / Suppliers)
This flow is separate from the httpOnly cookie system and is NOT affected by this architecture.
### How It Works
1. The portal generates a unique token per artist/supplier, stored as a SHA-256 hash in the `artists` or `production_requests` table
2. The plaintext token is sent to the person (e.g. via email link)
3. The person accesses a portal URL with the token as a query parameter or `Authorization: Bearer` header
4. `PortalTokenMiddleware` validates the hash, resolves the person and event context
5. The request proceeds with `portal_context`, `portal_person`, and `portal_event` attributes
### Security Properties
- Tokens are hashed at rest (SHA-256)
- No cookies or sessions involved — each request is independently authenticated
- Token validity is tied to event status (draft and closed events reject tokens)
- No user account required — the token IS the identity
---
## 7. Middleware Stack (Relevant Portion)
```
Request
→ CookieBearerToken (reads cookie → injects Authorization header)
→ auth:sanctum (validates bearer token)
→ Controller
```
For portal token routes:
```
Request
→ portal.token (validates portal-specific token)
→ Controller
```
---
## 8. Configuration Reference
| Setting | Location | Purpose |
|---------|----------|---------|
| `SESSION_DOMAIN` | `.env` | Cookie domain (`.crewli.app` in prod, `localhost` in dev) |
| `FRONTEND_ADMIN_URL` | `.env` / `config/app.php` | Admin SPA origin (cookie name resolution + CORS) |
| `FRONTEND_APP_URL` | `.env` / `config/app.php` | App SPA origin |
| `FRONTEND_PORTAL_URL` | `.env` / `config/app.php` | Portal SPA origin |
| `sanctum.expiration` | `config/sanctum.php` | Token TTL (7 days = 10080 minutes) |