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:
28
CLAUDE.md
28
CLAUDE.md
@@ -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)
|
||||
|
||||
|
||||
7
Makefile
7
Makefile
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
2
apps/app/auto-imports.d.ts
vendored
2
apps/app/auto-imports.d.ts
vendored
@@ -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']>
|
||||
|
||||
1
apps/app/components.d.ts
vendored
1
apps/app/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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",
|
||||
|
||||
3
apps/app/pnpm-lock.yaml
generated
3
apps/app/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Binary file not shown.
@@ -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()
|
||||
}
|
||||
|
||||
15
deploy.sh
15
deploy.sh
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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;
|
||||
@@ -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 (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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user