From 5331be4279e88733b8951b595861a8be246faa6e Mon Sep 17 00:00:00 2001 From: Sluijsens Date: Mon, 22 Jun 2026 15:42:56 +0200 Subject: [PATCH] fix(auth): configurable SameSite cookie for cross-origin dev setup Chrome 89+ schemeful same-site treats http://localhost and https://localhost as different sites, blocking SameSite=Strict cookies on cross-origin fetch (e.g. Vite on port 5173, API on port 7221). Fix: make CookieSameSite configurable per environment in JwtSettings. - Default: Strict (production) - appsettings.Development.json: None (allows cross-origin cookie in dev) - When SameSite=None, Secure is always forced (browser requirement) Revert the earlier Vite proxy approach in favour of this backend config. VITE_API_BASE_URL remains a freely configurable URL in .env.local. Co-Authored-By: Claude Haiku 4.5 --- frontend/.env.example | 15 +++++---------- frontend/src/lib/config.ts | 3 +-- frontend/vite.config.ts | 11 ----------- .../appsettings.Development.json | 3 ++- .../Identity/Models/JwtSettings.cs | 6 ++++++ .../Controllers/AuthController.cs | 13 ++++++++++--- 6 files changed, 24 insertions(+), 27 deletions(-) diff --git a/frontend/.env.example b/frontend/.env.example index 7525f13..e3597da 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,11 +1,6 @@ # Base URL of the SlpModularCms .NET API. -# -# LOCAL DEV (recommended): leave empty and configure the Vite proxy in -# vite.config.ts. The proxy forwards /api/* to the backend so all requests -# stay same-origin — required for the SameSite=Strict refresh-token cookie. -VITE_API_BASE_URL= - -# DIRECT (no proxy): point to the backend URL. CORS must allow this origin -# with credentials, and SameSite may block the cookie on page reload if the -# schemes differ (e.g. http frontend → https backend). -# VITE_API_BASE_URL=https://localhost:7221 +# The backend must be running with CORS configured to allow this origin +# and to send the httpOnly refresh-token cookie (credentials: include). +# Set CookieSameSite=None in appsettings.Development.json so the cookie +# is sent cross-origin when the frontend and backend run on different ports. +VITE_API_BASE_URL=https://localhost:7221 diff --git a/frontend/src/lib/config.ts b/frontend/src/lib/config.ts index 9e75c80..83a240a 100644 --- a/frontend/src/lib/config.ts +++ b/frontend/src/lib/config.ts @@ -6,8 +6,7 @@ import { z } from 'zod'; * once and only warns in development — production trusts the build-time env. */ const configSchema = z.object({ - // Empty string = use Vite proxy (same-origin); a full URL = direct mode. - apiBaseUrl: z.union([z.literal(''), z.string().url()]), + apiBaseUrl: z.string().url(), }); export type AppConfig = z.infer; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 06c2176..39e8ac2 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -14,17 +14,6 @@ export default defineConfig({ }, server: { port: 5173, - proxy: { - // Route all /api calls through the Vite dev server so the browser - // sees a single origin. Without this the refreshToken cookie - // (SameSite=Strict) is not sent from http://localhost to - // https://localhost (different scheme = cross-site in Chrome 89+). - '/api': { - target: 'https://localhost:7221', - changeOrigin: true, - secure: false, // allow self-signed dev cert - }, - }, }, test: { globals: true, diff --git a/src/SlpModularCms.Api/appsettings.Development.json b/src/SlpModularCms.Api/appsettings.Development.json index f97caec..b7c1215 100644 --- a/src/SlpModularCms.Api/appsettings.Development.json +++ b/src/SlpModularCms.Api/appsettings.Development.json @@ -13,7 +13,8 @@ "Issuer": "SlpModularCms", "Audience": "SlpModularCmsPortal", "ExpiryMinutes": 60, - "RefreshTokenExpiryDays": 7 + "RefreshTokenExpiryDays": 7, + "CookieSameSite": "None" }, "Availability": { "CircuitBreakerSeconds": 30, diff --git a/src/SlpModularCms.Core/Identity/Models/JwtSettings.cs b/src/SlpModularCms.Core/Identity/Models/JwtSettings.cs index 853bc21..48a5c01 100644 --- a/src/SlpModularCms.Core/Identity/Models/JwtSettings.cs +++ b/src/SlpModularCms.Core/Identity/Models/JwtSettings.cs @@ -10,4 +10,10 @@ public class JwtSettings public string Audience { get; set; } = string.Empty; public int ExpiryMinutes { get; set; } = 60; public int RefreshTokenExpiryDays { get; set; } = 7; + /// + /// SameSite-modus voor de refreshToken cookie: Strict | Lax | None. + /// Gebruik None in ontwikkelomgevingen waar frontend en backend + /// op verschillende poorten draaien (vereist Secure=true). + /// + public string CookieSameSite { get; set; } = "Strict"; } diff --git a/src/SlpModularCms.Modules.Identity/Controllers/AuthController.cs b/src/SlpModularCms.Modules.Identity/Controllers/AuthController.cs index 70d82fe..98f6256 100644 --- a/src/SlpModularCms.Modules.Identity/Controllers/AuthController.cs +++ b/src/SlpModularCms.Modules.Identity/Controllers/AuthController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Options; using SlpModularCms.Core.Identity.Models; using SlpModularCms.Core.Identity.Services; @@ -12,10 +13,12 @@ namespace SlpModularCms.Modules.Identity.Controllers; public class AuthController : ControllerBase { private readonly IAuthService _authService; + private readonly JwtSettings _jwtSettings; - public AuthController(IAuthService authService) + public AuthController(IAuthService authService, IOptions jwtSettings) { _authService = authService; + _jwtSettings = jwtSettings.Value; } [HttpPost("login")] @@ -65,11 +68,15 @@ public class AuthController : ControllerBase private CookieOptions GetCookieOptions() { + var sameSite = Enum.TryParse(_jwtSettings.CookieSameSite, ignoreCase: true, out var parsed) + ? parsed + : SameSiteMode.Strict; + return new CookieOptions { HttpOnly = true, - Secure = Request.IsHttps, - SameSite = SameSiteMode.Strict, + Secure = Request.IsHttps || sameSite == SameSiteMode.None, + SameSite = sameSite, Path = "/api/v1/auth", Expires = DateTimeOffset.UtcNow.AddDays(7) };