Merge pull request 'WS-3 PR-B2b: A13-3 + single-cookie + single-host (incl. flatpickr precursor)' (#6) from feat/ws-3-pr-b2b-single-cookie-deploy into main

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-05-06 01:16:05 +02:00
24 changed files with 249 additions and 278 deletions

View File

@@ -24,12 +24,11 @@ Design document: `/dev-docs/design-document.md`
- `composer rector` — Rector dry-run for modernisation suggestions.
See `/dev-docs/RECTOR.md`. Apply only in scoped sprints, never
automatically.
- ts-reset patches TypeScript's loosest default types in both SPAs.
- ts-reset patches TypeScript's loosest default types in the SPA.
See `/dev-docs/FRONTEND-TOOLING.md`. New TypeScript code adheres
to ts-reset's stricter types automatically.
- Vitest — `apps/portal` has 113+ tests; `apps/app` currently has
no Vitest setup (tracked as TECH-APP-VITEST, must close before
S3b lands).
- Vitest — `apps/app` has Vitest with 213 tests as of WS-3 PR-B2a.
Test count grows with each PR; check `pnpm test` for current value.
## Development tooling
@@ -40,24 +39,23 @@ Design document: `/dev-docs/design-document.md`
## Repository layout
- `api/` — Laravel backend
- `apps/app/`Organizer SPA (main product app + Platform Admin for super admins)
- `apps/portal/` — External portal (volunteers, artists, suppliers, etc.)
- `apps/app/`Single SPA covering organizers, volunteers, crew, super admins (context-routed in-app) plus the public form-fill / artist-advance flows
## Apps and portal architecture
## App architecture
- `apps/app/`Organizer: event management per organisation. Includes **Platform Admin** section (`/platform/*`) for super_admin users (organisation management, user management, impersonation, activity log).
- `apps/portal/` — External users: one app, two access modes:
- Login-based (`auth:sanctum`): volunteers, crew — persons with `user_id`
- Token-based (`portal.token` middleware): artists, suppliers, press — persons without `user_id`
`apps/app/`single workspace, two access modes:
- Login-based (`auth:sanctum`): organizers, volunteers, crew, super_admin. Includes **Platform Admin** section (`/platform/*`) for super_admin users (organisation management, user management, impersonation, activity log). Context-aware routing inside the SPA distinguishes organizer vs. volunteer experience based on `useAuthStore.availableContexts` (see `dev-docs/AUTH_ARCHITECTURE.md`).
- Token-based (`portal.token` middleware): artists, suppliers, press — persons without `user_id`. Stateless per-request token via `Authorization: Bearer` header or `?token=` query parameter.
### CORS
Configure two frontend origins in both Laravel (`config/cors.php` via env) and the Vite dev server proxy:
Single frontend origin in both Laravel (`config/cors.php` via env) and the Vite dev server proxy:
- app: `localhost:5174`
- portal: `localhost:5175`
- dev: `localhost:5174`
- prod: `https://crewli.app`
**Production (`crewli.app`):** API `https://api.crewli.app`, SPAs `https://crewli.app`, `https://portal.crewli.app` — see `api/.env.example` for `FRONTEND_*` and `SANCTUM_STATEFUL_DOMAINS`. **`crewli.nl`** is only for a future marketing site; this application stack uses **`crewli.app`** (not `.nl` for API, SPAs, or transactional mail).
See `api/.env.example` for `FRONTEND_*` and `SANCTUM_STATEFUL_DOMAINS`. **`crewli.nl`** is only for a future marketing site; this application stack uses **`crewli.app`** (not `.nl` for API, SPA, or transactional mail).
## Backend rules (strict)

View File

@@ -1,4 +1,4 @@
.PHONY: help services services-stop api app portal docs migrate fresh db-shell test test-db-create schema-dump
.PHONY: help services services-stop api app docs migrate fresh db-shell test test-db-create schema-dump
# Colors
GREEN := \033[0;32m
@@ -19,7 +19,6 @@ help:
@echo " $(YELLOW)Development Servers:$(NC)"
@echo " make api Laravel API → http://localhost:8000"
@echo " make app Organizer SPA → http://localhost:5174"
@echo " make portal Portal SPA → http://localhost:5175"
@echo " make docs VitePress docs → http://localhost:5176"
@echo ""
@echo " $(YELLOW)Database:$(NC)"
@@ -58,10 +57,6 @@ app:
@echo "$(GREEN)Starting Organizer SPA → http://localhost:5174$(NC)"
@cd apps/app && pnpm dev
portal:
@echo "$(GREEN)Starting Portal SPA → http://localhost:5175$(NC)"
@cd apps/portal && pnpm dev
docs:
@echo "$(GREEN)Starting VitePress docs → http://localhost:5176$(NC)"
@cd docs && npm run docs:dev

View File

@@ -21,10 +21,9 @@ Implementation is phased; the authoritative feature and schema list lives in the
| App | Path | Port | Role |
|-----|------|------|------|
| **Organizer** | `apps/app/` | 5174 | Main product for **org and event staff**: events, sections, shifts, people, artists, accreditation, briefings, reports. Includes **Platform Admin** section for super admins (`/platform/*`). |
| **Portal** | `apps/portal/` | 5175 | **External** users: stripped layout; login- or token-based access. |
| **SPA** | `apps/app/` | 5174 | Single-SPA product covering **organizers, volunteers, crew, super admins** (context-routed in-app), plus token-based access for artists, suppliers, press. Includes **Platform Admin** section for super admins (`/platform/*`). |
All apps talk to the API over **CORS** with **Laravel Sanctum** tokens.
The SPA talks to the API over **CORS** with **Laravel Sanctum** tokens.
---

View File

@@ -4,12 +4,12 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Auth;
use App\Enums\MfaMethod;
use App\Http\Controllers\Api\V1\Traits\SetAuthCookie;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\Auth\MfaEmailSendRequest;
use App\Http\Requests\Api\V1\Auth\MfaVerifyRequest;
use App\Http\Resources\Api\V1\MeResource;
use App\Enums\MfaMethod;
use App\Models\User;
use App\Services\MfaService;
use Illuminate\Http\JsonResponse;
@@ -58,19 +58,18 @@ final class MfaVerifyController extends Controller
]);
$token = $user->createToken('auth-token')->plainTextToken;
$cookieName = $this->resolveCookieName($request);
return $this->success([
'user' => new MeResource($user),
], 'MFA verification successful')
->withCookie($this->makeAuthCookie($cookieName, $token));
->withCookie($this->makeAuthCookie($token));
}
public function sendEmailCode(MfaEmailSendRequest $request): JsonResponse
{
$sessionToken = $request->validated('mfa_session_token');
$cacheKey = 'mfa_session:' . $sessionToken;
$cacheKey = 'mfa_session:'.$sessionToken;
$session = Cache::get($cacheKey);
if (! $session) {

View File

@@ -24,7 +24,6 @@ final class AuthRefreshController extends Controller
// Create a new token
$newToken = $user->createToken('auth-token')->plainTextToken;
$cookieName = $this->resolveCookieName($request);
$user->load(['organisations', 'roles', 'permissions']);
@@ -34,6 +33,6 @@ final class AuthRefreshController extends Controller
]);
return $this->success(new MeResource($user), 'Token refreshed')
->withCookie($this->makeAuthCookie($cookieName, $newToken));
->withCookie($this->makeAuthCookie($newToken));
}
}

View File

@@ -18,6 +18,7 @@ use Illuminate\Support\Facades\Gate;
final class InvitationController extends Controller
{
use SetAuthCookie;
public function __construct(
private readonly InvitationService $invitationService,
) {}
@@ -65,7 +66,6 @@ final class InvitationController extends Controller
);
$sanctumToken = $user->createToken('auth-token')->plainTextToken;
$cookieName = $this->resolveCookieName($request);
return $this->success([
'user' => [
@@ -76,7 +76,7 @@ final class InvitationController extends Controller
'email' => $user->email,
],
], 'Uitnodiging geaccepteerd')
->withCookie($this->makeAuthCookie($cookieName, $sanctumToken));
->withCookie($this->makeAuthCookie($sanctumToken));
}
public function revoke(Organisation $organisation, UserInvitation $invitation): JsonResponse

View File

@@ -65,13 +65,11 @@ final class LoginController extends Controller
// Return MFA challenge — NO auth token, NO auth cookie.
// Expire the auth cookie to invalidate any stale browser session.
$cookieName = $this->resolveCookieName($request);
return response()->json([
'success' => true,
'mfa_required' => true,
...$mfaSession,
])->withCookie($this->forgetAuthCookie($cookieName));
])->withCookie($this->forgetAuthCookie());
}
// MFA required by policy but not yet set up — issue token with flag
@@ -80,11 +78,10 @@ final class LoginController extends Controller
$data = $response->getData(true);
$data['mfa_setup_required'] = true;
$cookieName = $this->resolveCookieName($request);
$token = $user->createToken('auth-token')->plainTextToken;
return response()->json($data)
->withCookie($this->makeAuthCookie($cookieName, $token));
->withCookie($this->makeAuthCookie($token));
}
// No MFA — issue token as normal
@@ -101,11 +98,10 @@ final class LoginController extends Controller
]);
$token = $user->createToken('auth-token')->plainTextToken;
$cookieName = $this->resolveCookieName($request);
return $this->success([
'user' => new MeResource($user),
], 'Login successful')
->withCookie($this->makeAuthCookie($cookieName, $token));
->withCookie($this->makeAuthCookie($token));
}
}

View File

@@ -17,9 +17,7 @@ final class LogoutController extends Controller
{
$request->user()->currentAccessToken()->delete();
$cookieName = $this->resolveCookieName($request);
return $this->success(null, 'Logged out successfully')
->withCookie($this->forgetAuthCookie($cookieName));
->withCookie($this->forgetAuthCookie());
}
}

View File

@@ -4,42 +4,18 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Traits;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Cookie;
trait SetAuthCookie
{
private const COOKIE_MAP = [
'app' => 'crewli_app_token',
'portal' => 'crewli_portal_token',
];
private const COOKIE_NAME = 'crewli_app_token';
private const COOKIE_TTL_MINUTES = 60 * 24 * 7; // 7 days
protected function resolveCookieName(Request $request): string
{
$origin = $request->headers->get('Origin')
?? $request->headers->get('Referer')
?? '';
$appUrl = config('app.frontend_app_url', 'http://localhost:5174');
$portalUrl = config('app.frontend_portal_url', 'http://localhost:5175');
if ($this->originMatches($origin, $appUrl)) {
return self::COOKIE_MAP['app'];
}
if ($this->originMatches($origin, $portalUrl)) {
return self::COOKIE_MAP['portal'];
}
return self::COOKIE_MAP['app'];
}
protected function makeAuthCookie(string $cookieName, string $token): Cookie
protected function makeAuthCookie(string $token): Cookie
{
return new Cookie(
name: $cookieName,
name: self::COOKIE_NAME,
value: $token,
expire: now()->addMinutes(self::COOKIE_TTL_MINUTES),
path: '/',
@@ -50,10 +26,10 @@ trait SetAuthCookie
);
}
protected function forgetAuthCookie(string $cookieName): Cookie
protected function forgetAuthCookie(): Cookie
{
return new Cookie(
name: $cookieName,
name: self::COOKIE_NAME,
value: '',
expire: now()->subMinute(),
path: '/',
@@ -63,19 +39,4 @@ trait SetAuthCookie
sameSite: 'Strict',
);
}
private function originMatches(string $origin, string $configuredUrl): bool
{
if ($origin === '' || $configuredUrl === '') {
return false;
}
// Parse to compare host+port, ignoring trailing slashes and paths
$originHost = parse_url($origin, PHP_URL_HOST);
$originPort = parse_url($origin, PHP_URL_PORT);
$configHost = parse_url($configuredUrl, PHP_URL_HOST);
$configPort = parse_url($configuredUrl, PHP_URL_PORT);
return $originHost === $configHost && $originPort === $configPort;
}
}

View File

@@ -10,74 +10,21 @@ use Symfony\Component\HttpFoundation\Response;
final class CookieBearerToken
{
private const COOKIE_NAMES = [
'crewli_app_token',
'crewli_portal_token',
];
private const COOKIE_NAME = 'crewli_app_token';
public function handle(Request $request, Closure $next): Response
{
// Skip if an Authorization header is already present
// Skip if an Authorization header is already present (e.g. portal-token
// Bearer flow for artists/suppliers, or server-to-server callers).
if ($request->hasHeader('Authorization')) {
return $next($request);
}
// Resolve the cookie name for the requesting app via Origin header.
// This prevents cross-app cookie leakage on localhost where the
// browser sends all cookies regardless of port.
$cookieName = $this->resolveCookieName($request);
if ($cookieName) {
$token = $request->cookie($cookieName);
if ($token) {
$request->headers->set('Authorization', 'Bearer ' . $token);
}
$token = $request->cookie(self::COOKIE_NAME);
if ($token) {
$request->headers->set('Authorization', 'Bearer '.$token);
}
return $next($request);
}
private function resolveCookieName(Request $request): ?string
{
$origin = $request->headers->get('Origin')
?? $request->headers->get('Referer')
?? '';
if ($origin === '') {
// No Origin — fall back to first available cookie (e.g. server-to-server)
foreach (self::COOKIE_NAMES as $name) {
if ($request->cookie($name)) {
return $name;
}
}
return null;
}
$originHost = parse_url($origin, PHP_URL_HOST);
$originPort = parse_url($origin, PHP_URL_PORT);
$map = [
'app' => [config('app.frontend_app_url', 'http://localhost:5174'), 'crewli_app_token'],
'portal' => [config('app.frontend_portal_url', 'http://localhost:5175'), 'crewli_portal_token'],
];
foreach ($map as [$configuredUrl, $cookieName]) {
$configHost = parse_url($configuredUrl, PHP_URL_HOST);
$configPort = parse_url($configuredUrl, PHP_URL_PORT);
if ($originHost === $configHost && $originPort === $configPort) {
return $cookieName;
}
}
// Origin didn't match any configured frontend — fall back to first available
foreach (self::COOKIE_NAMES as $name) {
if ($request->cookie($name)) {
return $name;
}
}
return null;
}
}

View File

@@ -7,7 +7,6 @@ namespace Tests\Feature\Security;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
final class HttpOnlyCookieAuthTest extends TestCase
@@ -45,7 +44,7 @@ final class HttpOnlyCookieAuthTest extends TestCase
$response = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
], ['Origin' => 'http://localhost:5174']);
]);
$response->assertOk();
$response->assertCookie('crewli_app_token');
@@ -58,7 +57,7 @@ final class HttpOnlyCookieAuthTest extends TestCase
$response = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
], ['Origin' => 'http://localhost:5174']);
]);
$cookie = $this->findCookie($response, 'crewli_app_token');
$this->assertNotNull($cookie, 'Cookie crewli_app_token not found');
@@ -72,37 +71,41 @@ final class HttpOnlyCookieAuthTest extends TestCase
$response = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
], ['Origin' => 'http://localhost:5174']);
]);
$cookie = $this->findCookie($response, 'crewli_app_token');
$this->assertNotNull($cookie);
$this->assertEquals('strict', strtolower($cookie->getSameSite()));
}
public function test_login_sets_app_cookie_for_unknown_origin(): void
public function test_login_sets_app_cookie_regardless_of_origin(): void
{
$user = User::factory()->create();
// Post-WS-3 PR-B2b: there is no per-app cookie resolution. Whatever
// Origin (or no Origin) the request carries, the auth cookie issued
// is always crewli_app_token. The request body alone determines auth.
$cases = [
'no Origin header' => [],
'app Origin' => ['Origin' => 'http://localhost:5174'],
'unknown Origin' => ['Origin' => 'http://localhost:9999'],
'foreign Origin' => ['Origin' => 'https://elsewhere.example.com'],
];
$response = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
], ['Origin' => 'http://localhost:9999']);
foreach ($cases as $label => $headers) {
$user = User::factory()->create();
$response->assertOk();
$response->assertCookie('crewli_app_token');
}
$response = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
], $headers);
public function test_login_sets_portal_cookie_for_portal_origin(): void
{
$user = User::factory()->create();
$response->assertOk();
$response = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
], ['Origin' => 'http://localhost:5175']);
$response->assertOk();
$response->assertCookie('crewli_portal_token');
$cookie = $this->findCookie($response, 'crewli_app_token');
$this->assertNotNull(
$cookie,
"crewli_app_token must be set for case: {$label}",
);
}
}
// --- Middleware Tests ---
@@ -142,7 +145,7 @@ final class HttpOnlyCookieAuthTest extends TestCase
$token = $user->createToken('auth-token')->plainTextToken;
$response = $this->withUnencryptedCookie('crewli_app_token', $token)
->postJson('/api/v1/auth/logout', [], ['Origin' => 'http://localhost:5174']);
->postJson('/api/v1/auth/logout');
$response->assertOk();
@@ -161,7 +164,7 @@ final class HttpOnlyCookieAuthTest extends TestCase
$token = $accessToken->plainTextToken;
$response = $this->withUnencryptedCookie('crewli_app_token', $token)
->postJson('/api/v1/auth/refresh', [], ['Origin' => 'http://localhost:5174']);
->postJson('/api/v1/auth/refresh');
$response->assertOk();
$response->assertCookie('crewli_app_token');
@@ -192,45 +195,6 @@ final class HttpOnlyCookieAuthTest extends TestCase
$response->assertUnauthorized();
}
// --- Cross-App Isolation Tests ---
public function test_app_cookie_does_not_authenticate_portal_requests(): void
{
$user = User::factory()->create();
$token = $user->createToken('auth-token')->plainTextToken;
// App cookie is set, but request comes from portal origin —
// middleware should only read crewli_portal_token, not crewli_app_token
$response = $this->withUnencryptedCookie('crewli_app_token', $token)
->getJson('/api/v1/auth/me', ['Origin' => 'http://localhost:5175']);
$response->assertUnauthorized();
}
public function test_portal_cookie_does_not_authenticate_app_requests(): void
{
$user = User::factory()->create();
$token = $user->createToken('auth-token')->plainTextToken;
$response = $this->withUnencryptedCookie('crewli_portal_token', $token)
->getJson('/api/v1/auth/me', ['Origin' => 'http://localhost:5174']);
$response->assertUnauthorized();
}
public function test_correct_cookie_authenticates_with_matching_origin(): void
{
$user = User::factory()->create();
$token = $user->createToken('auth-token')->plainTextToken;
// Portal cookie + portal origin = authenticated
$response = $this->withUnencryptedCookie('crewli_portal_token', $token)
->getJson('/api/v1/auth/me', ['Origin' => 'http://localhost:5175']);
$response->assertOk();
$response->assertJsonPath('data.id', $user->id);
}
// --- Helper ---
private function findCookie($response, string $name): ?\Symfony\Component\HttpFoundation\Cookie

View File

@@ -139,6 +139,7 @@ declare global {
const registerPlugins_: typeof import('./src/@core/utils/plugins')['registerPlugins_']
const requiredValidator: typeof import('./src/@core/utils/validators')['requiredValidator']
const resolveComponent: typeof import('vue')['resolveComponent']
const resolvePostLoginTarget: typeof import('./src/utils/postLoginRedirect')['resolvePostLoginTarget']
const resolveRef: typeof import('@vueuse/core')['resolveRef']
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
const resolveVuetifyTheme: typeof import('./src/@core/utils/vuetify')['resolveVuetifyTheme']
@@ -519,6 +520,7 @@ declare module 'vue' {
readonly registerPlugins: UnwrapRef<typeof import('./src/@core/utils/plugins')['registerPlugins']>
readonly requiredValidator: UnwrapRef<typeof import('./src/@core/utils/validators')['requiredValidator']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly resolvePostLoginTarget: UnwrapRef<typeof import('./src/utils/postLoginRedirect')['resolvePostLoginTarget']>
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']>
readonly resolveVuetifyTheme: UnwrapRef<typeof import('./src/@core/utils/vuetify')['resolveVuetifyTheme']>

View File

@@ -36,6 +36,7 @@ declare module 'vue' {
ClaimenTab: typeof import('./src/components/portal/event/ClaimenTab.vue')['default']
CompanyDialog: typeof import('./src/components/organisation/CompanyDialog.vue')['default']
ConfirmDialog: typeof import('./src/components/dialogs/ConfirmDialog.vue')['default']
ContextSwitcher: typeof import('./src/components/shared/ContextSwitcher.vue')['default']
CreateAppDialog: typeof import('./src/components/dialogs/CreateAppDialog.vue')['default']
CreateEventDialog: typeof import('./src/components/events/CreateEventDialog.vue')['default']
CreatePersonDialog: typeof import('./src/components/persons/CreatePersonDialog.vue')['default']

View File

@@ -38,6 +38,7 @@
"cookie-es": "1.2.2",
"destr": "2.0.5",
"eslint-plugin-regexp": "2.10.0",
"flatpickr": "^4.6.13",
"jwt-decode": "4.0.0",
"mapbox-gl": "3.5.2",
"ofetch": "1.5.0",

View File

@@ -75,6 +75,9 @@ importers:
eslint-plugin-regexp:
specifier: 2.10.0
version: 2.10.0(eslint@8.57.1)
flatpickr:
specifier: ^4.6.13
version: 4.6.13
jwt-decode:
specifier: 4.0.0
version: 4.0.0

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import FlatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import { useTheme } from 'vuetify'
// @ts-expect-error There won't be declaration file for it
@@ -218,10 +219,9 @@ const elementId = computed (() => {
<style lang="scss">
@use "@core/scss/template/mixins" as templateMixins;
@use "@core/scss/base/mixins";
/* stylelint-disable no-descending-specificity */
@use "flatpickr/dist/flatpickr.css";
@use "@core/scss/base/mixins";
.flat-picker-custom-style {
position: absolute;

View File

@@ -1,19 +1,49 @@
/**
* Resolve the post-login redirect target. If the caller supplied a `?to=`
* query that's a same-origin relative path, honour it; otherwise fall back
* to the auth-store's resolveLandingRoute().
* query that is a same-origin, well-formed relative path, honour it;
* otherwise fall back to the auth-store's resolveLandingRoute().
*
* The `startsWith('/')` + `!startsWith('//')` guard is the **minimum**
* A13-3 (open-redirect) precaution. Full domain-validation lands in
* WS-3 PR-B2b.
* `isSafeRelativePath` rejects every input that is not a strict relative
* path: missing/empty, absolute, protocol-relative (`//`), backslash-bearing
* (browsers normalise `\` → `/` in some contexts), control-character-bearing,
* or anything the URL constructor parses to a different origin than our
* synthetic invalid origin. The URL-constructor check is the authoritative
* guard — the prefix and character checks are fast pre-filters.
*
* Closes A13-3 (open-redirect on post-login). The minimum precaution from
* WS-3 PR-B2a (`startsWith('/') && !startsWith('//')`) is now superseded.
*/
const SYNTHETIC_ORIGIN = 'https://__crewli_safe_relative_check__.invalid'
function isSafeRelativePath(to: string): boolean {
if (!to || !to.startsWith('/') || to.startsWith('//'))
return false
if (to.includes('\\'))
return false
// eslint-disable-next-line no-control-regex
if (/[\x00-\x1F\x7F]/.test(to))
return false
try {
const url = new URL(to, SYNTHETIC_ORIGIN)
if (url.origin !== SYNTHETIC_ORIGIN)
return false
}
catch {
return false
}
return true
}
export function resolvePostLoginTarget(
rawTo: string | null | undefined,
fallback: () => string,
): string {
const to = rawTo ?? ''
if (to.startsWith('/') && !to.startsWith('//'))
return to
return fallback()
return isSafeRelativePath(to) ? to : fallback()
}

View File

@@ -93,18 +93,13 @@ else
echo "→ package-lock.json unchanged — skipping npm ci"
fi
echo "→ Building frontend assets (apps/app + apps/portal)..."
# Explicit per-workspace build to avoid silent single-app builds
echo "→ Building frontend assets (apps/app)..."
npm run build -w apps/app
npm run build -w apps/portal
# Verify both dist folders exist and are non-empty
for app in app portal; do
if [ ! -f "apps/$app/dist/index.html" ]; then
echo "❌ Build failed: apps/$app/dist/index.html missing"
exit 1
fi
done
if [ ! -f "apps/app/dist/index.html" ]; then
echo "❌ Build failed: apps/app/dist/index.html missing"
exit 1
fi
# ──────────────────────────────────────────
# 5. Run migrations

View File

@@ -28,18 +28,26 @@ server {
}
```
### Portal (portal.crewli.app)
### Legacy portal redirect (portal.crewli.app)
Pre-WS-3 (April 2026), Crewli ran a separate portal SPA at
`portal.crewli.app`. The dual-SPA was consolidated into a single
workspace; the legacy host should redirect 301 → `crewli.app`:
```nginx
server {
server_name portal.crewli.app;
listen 443 ssl;
# ... TLS config from DirectAdmin / Let's Encrypt ...
include /path/to/deploy/nginx/security-headers.conf;
include /path/to/deploy/nginx/csp-portal.conf;
# ... rest of config
return 301 https://crewli.app$request_uri;
}
```
DNS retirement of `portal.crewli.app` is a separate operational task
tracked outside this repo. Until DNS is repointed, this redirect
handles any stale links.
## CSP Rollout Process
1. Start with `Content-Security-Policy-Report-Only` (uncomment in `csp-spa.conf`)

View File

@@ -1,4 +0,0 @@
# CSP for portal.crewli.app
# Same policy as SPA but with stricter connect-src since portal
# should only talk to the API.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://api.crewli.app; frame-ancestors 'none'; form-action 'self'; base-uri 'self'" always;

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.

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
**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)
#### [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)