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 # 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 > Audience: security auditors, backend developers
--- ---
## 1. Authentication Overview ## 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 | URL (dev) | URL (prod) | Purpose |
|-----|-----------|------------|---------| |-----|-----------|------------|---------|
| App | localhost:5174 | crewli.app | Organiser dashboard + platform admin (`/platform/*` for super_admin) | | SPA | localhost:5174 | crewli.app | Organizers, volunteers, crew, super_admin (context-routed in-app) |
| Portal | localhost:5175 | portal.crewli.app | Volunteers, artists, suppliers |
### Access Modes ### 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. **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 ## 2. Cookie Specification
| App | Cookie Name | Domain | Secure | httpOnly | SameSite | Max-Age | | Cookie Name | Domain | Secure | httpOnly | SameSite | Max-Age |
|-----|-------------|--------|--------|----------|----------|---------| |-------------|--------|--------|----------|----------|---------|
| App | `crewli_app_token` | `.crewli.app` (prod) / `localhost` (dev) | Yes (prod) | Yes | Strict | 7 days | | `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`. 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 ### Creation
On successful login (`POST /auth/login`), the server: On successful login (`POST /auth/login`), the server:
1. Validates credentials via `Auth::attempt()` 1. Validates credentials
2. Creates a Sanctum personal access token 2. Creates a Sanctum personal access token
3. Resolves the cookie name from the `Origin` header 3. Returns user data in the JSON body (no token in body)
4. 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`)
5. Attaches the token as a `Set-Cookie` header with httpOnly flag
### Validation ### Validation
The `CookieBearerToken` middleware (registered before `auth:sanctum` in the API middleware stack): 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 1. Skips if an `Authorization` header is already present (portal-token flow, server-to-server callers)
2. Resolves the correct cookie name for that app (e.g. portal origin → `crewli_portal_token`) 2. Reads the `crewli_app_token` cookie and sets `Authorization: Bearer <token>` on the request
3. Reads only that cookie and sets `Authorization: Bearer` on the request 3. Sanctum's existing token validation processes the header normally
4. 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. 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.
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 ### Rotation
@@ -149,12 +143,12 @@ This flow is separate from the httpOnly cookie system and is NOT affected by thi
``` ```
Request Request
→ CookieBearerToken (reads cookie → injects Authorization header) → CookieBearerToken (cookie → Authorization header)
→ auth:sanctum (validates bearer token) → auth:sanctum (validates bearer token)
→ Controller → Controller
``` ```
For portal token routes: For portal-token routes (artists / suppliers / press):
``` ```
Request Request
→ portal.token (validates portal-specific token) → portal.token (validates portal-specific token)
@@ -168,8 +162,8 @@ Request
| Setting | Location | Purpose | | Setting | Location | Purpose |
|---------|----------|---------| |---------|----------|---------|
| `SESSION_DOMAIN` | `.env` | Cookie domain (`.crewli.app` in prod, `localhost` in dev) | | `SESSION_DOMAIN` | `.env` | Cookie domain (`.crewli.app` in prod, `localhost` in dev) |
| `FRONTEND_APP_URL` | `.env` / `config/app.php` | App SPA origin | | `FRONTEND_APP_URL` | `.env` / `config/app.php` | SPA origin |
| `FRONTEND_PORTAL_URL` | `.env` / `config/app.php` | Portal 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) | | `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/Http/Requests/Admin/StartImpersonationRequest.php` | Validation for start request |
| `app/Models/ImpersonationSession.php` | Eloquent model with `HasUlids`, `scopeActive()` | | `app/Models/ImpersonationSession.php` | Eloquent model with `HasUlids`, `scopeActive()` |
| `app/Http/Resources/Admin/ImpersonationSessionResource.php` | API resource for session data | | `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.

View File

@@ -823,6 +823,86 @@ introduceert is het natuurlijke moment.
--- ---
### TECH-FRONTEND-URL-CONSOLIDATE — Refactor email controllers to drop per-app URL map
**Aanleiding:** WS-3 PR-B2b consolideerde naar één SPA en één
auth-cookie. Drie controllers bouwen nog een per-app URL map
(`'admin' / 'app' / 'portal' => config('app.frontend_*_url')`) voor
outbound emails. In productie resolven alle `FRONTEND_*` env vars
naar dezelfde host (`https://crewli.app`); de map-structuur is
functioneel redundant maar staat structureel intact.
**Wat:** Refactor de drie controllers om alleen `frontend_app_url`
te gebruiken. Verwijder de `'portal'` key uit de URL maps; collapse
naar een single-URL consumer. Email templates die schakelen op
`app === 'portal'` ook updaten.
**Files:**
- `api/app/Http/Controllers/Api/V1/EmailChangeController.php`
- `api/app/Http/Controllers/Api/V1/PasswordResetController.php`
- `api/app/Http/Controllers/Api/V1/PersonController.php`
- Email templates die de `app` parameter consumeren
**Prioriteit:** Laag — purely code-cleanliness, geen functionele of
security impact (productie env vars zijn al geconsolideerd). Effective
post-WS-3 PR-B2b.
---
### TECH-DOCS-APPS-PORTAL-PURGE — Sweep remaining apps/portal references from briefing/tooling docs
**Aanleiding:** WS-3 PR-B2b purgeerde `apps/portal` uit de
load-bearing files (`README.md`, `Makefile`, `CLAUDE.md`) en de
deploy-config. De briefing/tooling docs verwijzen nog steeds naar
de pre-consolidatie tweede SPA.
**Files:**
- `.cursor/instructions.md`
- `.cursor/ARCHITECTURE.md`
- `.cursor/rules/101_vue.mdc`
- `.cursor/rules/102_multi_tenancy.mdc`
- `dev-docs/MASTER_PROMPT_CC.md`
- `dev-docs/MASTER_PROMPT_CURSOR.md`
- `dev-docs/SETUP.md`
- `dev-docs/dev-guide.md`
- `dev-docs/CLAUDE_CODE_TOOLING.md`
**Skip:** `dev-docs/WS-3-SESSION-1C-AUDIT.md` — historical sprint
audit, frozen in time, references are factually correct for the
session it documents.
**Prioriteit:** Laag — single `chore(docs)` PR. Niet blokkerend voor
runtime; LLM/IDE briefings produceren licht stale context tot dit
landt. Effective post-WS-3 PR-B2b.
---
### OPS — Retire `portal.crewli.app` DNS record
**Aanleiding:** Post-WS-3 PR-B2b serves crewli.app als single SPA;
WS-3 PR-B2b's deploy-config voegt een 301-redirect server block toe
voor `portal.crewli.app → crewli.app$request_uri`. DNS is nog niet
gerepointed en de zone bestaat nog.
**Wat:** Operationele taak (geen code). Twee stappen:
1. Monitor traffic naar het redirect server block voor 30 dagen.
Bij significant verkeer: identificeer bron (oude bookmarks,
externe links) en notify stakeholders voordat retirement gaat
gebeuren.
2. Bij nul / negligible verkeer: repoint DNS record naar
`crewli.app` (CNAME), of verwijder de zone volledig en laat
het redirect server block in nginx config voor de happstige
transition.
**Prioriteit:** Laag — niet code, geen blocker. Pak op wanneer
analytics monitoring volwassen genoeg is om "is dit nog in gebruik?"
te beantwoorden. Geen deadline.
---
### TECH-PIVOT-ROLES-MULTI — Multi-role per (user, organisation) pivot ### TECH-PIVOT-ROLES-MULTI — Multi-role per (user, organisation) pivot
**Aanleiding:** WS-3 PR-B2a maakt context-aware routing op **Aanleiding:** WS-3 PR-B2a maakt context-aware routing op

View File

@@ -552,13 +552,11 @@ Audit scope: all files under `api/` and `apps/` (app, portal).
### Frontend Security (A13) ### Frontend Security (A13)
#### [CRITICAL] A13-1: Bearer tokens stored in `localStorage` (apps/app and apps/portal) #### ~~[CRITICAL] A13-1: Bearer tokens stored in `localStorage` (apps/app and apps/portal)~~ RESOLVED
- **File:** `apps/app/src/stores/useAuthStore.ts` - **File:** `apps/app/src/stores/useAuthStore.ts` (single SPA post WS-3)
- **File:** `apps/portal/src/stores/useAuthStore.ts` - **Description:** Pre-WS-3 (April 2026) the SPA layer used per-app cookies (`crewli_app_token`, `crewli_portal_token`) with Origin-based middleware resolution. WS-3 PR-B consolidated the dual SPAs into a single `apps/app` workspace; PR-B2b retired the dual-cookie machinery. The system now issues a single httpOnly `crewli_app_token` cookie. The localStorage-based bearer-token storage that this finding originally flagged was migrated to httpOnly cookies as part of the same consolidation arc.
- **Description:** Sanctum bearer tokens stored in `localStorage` under `crewli_token` and `crewli_portal_token`. Accessible to any JavaScript on the page. - **Resolution:** Tokens are httpOnly + Secure + SameSite=Strict, set server-side, never exposed to JavaScript. See `dev-docs/AUTH_ARCHITECTURE.md` for current architecture.
- **Risk:** Any XSS vulnerability (or supply-chain attack) can steal tokens and impersonate users indefinitely.
- **Fix:** Migrate to `httpOnly; Secure; SameSite=Strict` cookies set by the Laravel backend. Remove `setItem`/`getItem` usage.
#### ~~[HIGH] A13-2: Admin app cookies lack `httpOnly`, `Secure`, and `SameSite` flags~~ RETIRED #### ~~[HIGH] A13-2: Admin app cookies lack `httpOnly`, `Secure`, and `SameSite` flags~~ RETIRED
@@ -566,13 +564,12 @@ Audit scope: all files under `api/` and `apps/` (app, portal).
- **Description:** The admin SPA has been retired. Its functionality now lives in `apps/app/` under `/platform/*` routes. - **Description:** The admin SPA has been retired. Its functionality now lives in `apps/app/` under `/platform/*` routes.
- **Resolution:** Finding no longer applicable — `apps/admin/` has been removed. - **Resolution:** Finding no longer applicable — `apps/admin/` has been removed.
#### [HIGH] A13-3: Open redirect vulnerability on post-login redirect (all apps) #### ~~[HIGH] A13-3: Open redirect vulnerability on post-login redirect (all apps)~~ RESOLVED by WS-3 PR-B2b
- **File:** `apps/portal/src/pages/login.vue:61,74-76` - **File:** `apps/app/src/utils/postLoginRedirect.ts` (single SPA post WS-3)
- **File:** `apps/app/src/pages/login.vue:55` - **Description:** Login pages accepted `?to=` query parameter and redirected to it after login without validating it's a relative path.
- **Description:** All login pages accept `?to=` query parameter and redirect to it after login without validating it's a relative path. Portal falls back to `window.location.href` with the raw value. - **Risk:** Phishing: `https://crewli.app/login?to=https://evil.com/steal`.
- **Risk:** Phishing: `https://portal.crewli.app/login?to=https://evil.com/steal`. - **Resolution:** WS-3 PR-B2a introduced a minimum precaution (`startsWith('/') && !startsWith('//')`); WS-3 PR-B2b replaced it with full validation. The `isSafeRelativePath` helper in `apps/app/src/utils/postLoginRedirect.ts` now rejects empty input, non-`/`-prefixed paths, protocol-relative URLs, backslashes (browsers normalise `\``/`), ASCII control characters (`\x00``\x1F`, `\x7F`), and anything the URL constructor parses to a different origin than a synthetic invalid base. 16 vitest specs pin the contract.
- **Fix:** Validate that redirect target starts with `/` before using it.
#### ~~[HIGH] A13-4: `v-html` with API-sourced data in admin app (template pages)~~ RETIRED #### ~~[HIGH] A13-4: `v-html` with API-sourced data in admin app (template pages)~~ RETIRED
@@ -656,7 +653,7 @@ The following security measures ARE correctly implemented:
| 5 | A02-2: Set Sanctum token expiration | One line | | 5 | A02-2: Set Sanctum token expiration | One line |
| 6 | A02-1: Replace ULID tokens with cryptographic random | Small | | 6 | A02-1: Replace ULID tokens with cryptographic random | Small |
| 7 | A01-1: Implement PortalTokenMiddleware | Medium | | 7 | A01-1: Implement PortalTokenMiddleware | Medium |
| 8 | A13-3: Fix open redirect on login pages | Small | | 8 | ~~A13-3: Fix open redirect on login pages~~ ✅ resolved by WS-3 PR-B2b | Small |
### Short-term (within 1 sprint) ### Short-term (within 1 sprint)