diff --git a/dev-docs/AUTH_ARCHITECTURE.md b/dev-docs/AUTH_ARCHITECTURE.md index a09077c7..44cf916d 100644 --- a/dev-docs/AUTH_ARCHITECTURE.md +++ b/dev-docs/AUTH_ARCHITECTURE.md @@ -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 ` 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 (April–May 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. diff --git a/dev-docs/BACKLOG.md b/dev-docs/BACKLOG.md index b91bc50a..97e3e296 100644 --- a/dev-docs/BACKLOG.md +++ b/dev-docs/BACKLOG.md @@ -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 **Aanleiding:** WS-3 PR-B2a maakt context-aware routing op diff --git a/dev-docs/SECURITY_AUDIT.md b/dev-docs/SECURITY_AUDIT.md index cc734c7b..c57e3d34 100644 --- a/dev-docs/SECURITY_AUDIT.md +++ b/dev-docs/SECURITY_AUDIT.md @@ -552,13 +552,11 @@ Audit scope: all files under `api/` and `apps/` (app, portal). ### 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/portal/src/stores/useAuthStore.ts` -- **Description:** Sanctum bearer tokens stored in `localStorage` under `crewli_token` and `crewli_portal_token`. Accessible to any JavaScript on the page. -- **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. +- **File:** `apps/app/src/stores/useAuthStore.ts` (single SPA post WS-3) +- **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. +- **Resolution:** Tokens are httpOnly + Secure + SameSite=Strict, set server-side, never exposed to JavaScript. See `dev-docs/AUTH_ARCHITECTURE.md` for current architecture. #### ~~[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. - **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/pages/login.vue:55` -- **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://portal.crewli.app/login?to=https://evil.com/steal`. -- **Fix:** Validate that redirect target starts with `/` before using it. +- **File:** `apps/app/src/utils/postLoginRedirect.ts` (single SPA post WS-3) +- **Description:** Login pages accepted `?to=` query parameter and redirected to it after login without validating it's a relative path. +- **Risk:** Phishing: `https://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. #### ~~[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 | | 6 | A02-1: Replace ULID tokens with cryptographic random | Small | | 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)