docs(auth): reflect single-cookie architecture; close A13-3

dev-docs/AUTH_ARCHITECTURE.md (v1.0 → v2.0):
- Title section updated to single-SPA / single-cookie reality
- Client Applications table collapsed to one row
- Cookie Specification table collapsed to one row (crewli_app_token)
- Token Lifecycle / Validation section: Origin-based resolution
  language removed; middleware described as origin-agnostic
- Cross-app isolation paragraph removed (no second app)
- Configuration Reference table marks FRONTEND_PORTAL_URL as legacy,
  pointing at TECH-FRONTEND-URL-CONSOLIDATE
- New §11 "History" preserves the pre-WS-3 dual-cookie context for
  future readers, mentions PR-B2a + PR-B2b roles in the unwind

dev-docs/BACKLOG.md — three new entries:
- TECH-FRONTEND-URL-CONSOLIDATE: refactor email controllers to drop
  per-app URL map (EmailChangeController, PasswordResetController,
  PersonController) — low priority, code-cleanliness only
- TECH-DOCS-APPS-PORTAL-PURGE: sweep apps/portal references from
  briefing/tooling docs (.cursor/, MASTER_PROMPT_*, SETUP, dev-guide,
  CLAUDE_CODE_TOOLING) — single chore(docs) PR, low priority
- OPS — DNS retirement of portal.crewli.app — operational task,
  deferred until traffic monitoring confirms zero usage

dev-docs/SECURITY_AUDIT.md:
- A13-1 narrative actualised: pre-WS-3 dual-cookie context kept as
  history, status flipped to RESOLVED (the localStorage→httpOnly
  migration shipped earlier in the consolidation arc)
- A13-3: status flipped to RESOLVED by WS-3 PR-B2b; description
  rewritten to reflect the new postLoginRedirect.ts validator and
  the 16 spec coverage
- Priority remediation table item 8 strikes through A13-3

Backend test suite: 1487 passed (unchanged from Commit 2 baseline).
Frontend: 223 passed (unchanged from Commit 1 baseline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 00:29:26 +02:00
parent a748c9ee7a
commit 812cc17460
3 changed files with 119 additions and 40 deletions

View File

@@ -1,38 +1,36 @@
# Crewli — Authentication Architecture
> Version: 1.0 — April 2026
> Version: 2.0 — May 2026 (post WS-3 PR-B2b: single-cookie consolidation)
> Audience: security auditors, backend developers
---
## 1. Authentication Overview
Crewli uses **stateless token-based authentication** via Laravel Sanctum. Two 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.
Crewli uses **stateless token-based authentication** via Laravel Sanctum. A single SPA client communicates 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
### Client Application
| App | URL (dev) | URL (prod) | Purpose |
|-----|-----------|------------|---------|
| App | localhost:5174 | crewli.app | Organiser dashboard + platform admin (`/platform/*` for super_admin) |
| Portal | localhost:5175 | portal.crewli.app | Volunteers, artists, suppliers |
| SPA | localhost:5174 | crewli.app | Organizers, volunteers, crew, super_admin (context-routed in-app) |
### Access Modes
The Portal supports two access modes:
The SPA 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
1. **Cookie-based** (`auth:sanctum`): organizers, volunteers, crew — 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 |
|-----|-------------|--------|--------|----------|----------|---------|
| 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 |
| Cookie Name | Domain | Secure | httpOnly | SameSite | Max-Age |
|-------------|--------|--------|----------|----------|---------|
| `crewli_app_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`.
A single cookie covers all cookie-authenticated traffic. The cookie domain is configured via `SESSION_DOMAIN` in `.env`.
---
@@ -41,23 +39,19 @@ Each SPA gets its own cookie name to prevent shared auth state between apps. The
### Creation
On successful login (`POST /auth/login`), the server:
1. Validates credentials via `Auth::attempt()`
1. Validates credentials
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
3. Returns user data in the JSON body (no token in body)
4. Attaches the token as a `Set-Cookie` header with httpOnly flag (cookie name: `crewli_app_token`)
### 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
1. Skips if an `Authorization` header is already present (portal-token flow, server-to-server callers)
2. Reads the `crewli_app_token` cookie and sets `Authorization: Bearer <token>` on the request
3. Sanctum's existing token validation processes the header normally
**Cross-app isolation:** In local development, both SPAs share `localhost` (different ports). Browsers do not scope cookies by port, so both 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 the other.
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.
The middleware is origin-agnostic — there is no Origin/Referer parsing or per-app cookie resolution. With only one SPA, cross-app isolation is not a concern.
### Rotation
@@ -149,12 +143,12 @@ This flow is separate from the httpOnly cookie system and is NOT affected by thi
```
Request
→ CookieBearerToken (reads cookie → injects Authorization header)
→ CookieBearerToken (cookie → Authorization header)
→ auth:sanctum (validates bearer token)
→ Controller
```
For portal token routes:
For portal-token routes (artists / suppliers / press):
```
Request
→ portal.token (validates portal-specific token)
@@ -168,8 +162,8 @@ Request
| Setting | Location | Purpose |
|---------|----------|---------|
| `SESSION_DOMAIN` | `.env` | Cookie domain (`.crewli.app` in prod, `localhost` in dev) |
| `FRONTEND_APP_URL` | `.env` / `config/app.php` | App SPA origin |
| `FRONTEND_PORTAL_URL` | `.env` / `config/app.php` | Portal SPA origin |
| `FRONTEND_APP_URL` | `.env` / `config/app.php` | SPA origin |
| `FRONTEND_PORTAL_URL` | `.env` / `config/app.php` | Legacy — set to the same value as `FRONTEND_APP_URL` post-WS-3. Still consumed by outbound-email controllers (password-reset, email-change, person-create) for per-app URL maps; refactor tracked as `TECH-FRONTEND-URL-CONSOLIDATE`. |
| `sanctum.expiration` | `config/sanctum.php` | Token TTL (7 days = 10080 minutes) |
---
@@ -400,3 +394,11 @@ This applies to **all** activity log entries, not just impersonation-specific ev
| `app/Http/Requests/Admin/StartImpersonationRequest.php` | Validation for start request |
| `app/Models/ImpersonationSession.php` | Eloquent model with `HasUlids`, `scopeActive()` |
| `app/Http/Resources/Admin/ImpersonationSessionResource.php` | API resource for session data |
---
## 11. History — pre-WS-3 dual-cookie architecture
Pre-WS-3 (April 2026), Crewli ran two separate SPAs (`apps/app` for organizers, `apps/portal` for crew/volunteers) and the auth layer maintained per-app cookies (`crewli_app_token`, `crewli_portal_token`) with Origin-based resolution in both `CookieBearerToken` middleware and the `SetAuthCookie` controller trait.
WS-3 PR-B (AprilMay 2026) consolidated to a single SPA workspace. PR-B2a unified the frontend stores, axios factory, route guards, and `ContextSwitcher`. PR-B2b retired the dual-cookie machinery on the server: `crewli_portal_token` is fully purged, the Origin-resolution code paths are gone, and the auth cookie is unconditional. The Portal Token-Based Flow for artists/suppliers (described in §6) is unchanged — that mechanism is independent of the cookie flow and remains the canonical way to authenticate per-token portal links.