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 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 15:42:56 +02:00
co-authored by Claude Haiku 4.5
parent 546b773781
commit 5331be4279
6 changed files with 24 additions and 27 deletions
+5 -10
View File
@@ -1,11 +1,6 @@
# Base URL of the SlpModularCms .NET API. # Base URL of the SlpModularCms .NET API.
# # The backend must be running with CORS configured to allow this origin
# LOCAL DEV (recommended): leave empty and configure the Vite proxy in # and to send the httpOnly refresh-token cookie (credentials: include).
# vite.config.ts. The proxy forwards /api/* to the backend so all requests # Set CookieSameSite=None in appsettings.Development.json so the cookie
# stay same-origin — required for the SameSite=Strict refresh-token cookie. # is sent cross-origin when the frontend and backend run on different ports.
VITE_API_BASE_URL= VITE_API_BASE_URL=https://localhost:7221
# 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
+1 -2
View File
@@ -6,8 +6,7 @@ import { z } from 'zod';
* once and only warns in development — production trusts the build-time env. * once and only warns in development — production trusts the build-time env.
*/ */
const configSchema = z.object({ const configSchema = z.object({
// Empty string = use Vite proxy (same-origin); a full URL = direct mode. apiBaseUrl: z.string().url(),
apiBaseUrl: z.union([z.literal(''), z.string().url()]),
}); });
export type AppConfig = z.infer<typeof configSchema>; export type AppConfig = z.infer<typeof configSchema>;
-11
View File
@@ -14,17 +14,6 @@ export default defineConfig({
}, },
server: { server: {
port: 5173, 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: { test: {
globals: true, globals: true,
@@ -13,7 +13,8 @@
"Issuer": "SlpModularCms", "Issuer": "SlpModularCms",
"Audience": "SlpModularCmsPortal", "Audience": "SlpModularCmsPortal",
"ExpiryMinutes": 60, "ExpiryMinutes": 60,
"RefreshTokenExpiryDays": 7 "RefreshTokenExpiryDays": 7,
"CookieSameSite": "None"
}, },
"Availability": { "Availability": {
"CircuitBreakerSeconds": 30, "CircuitBreakerSeconds": 30,
@@ -10,4 +10,10 @@ public class JwtSettings
public string Audience { get; set; } = string.Empty; public string Audience { get; set; } = string.Empty;
public int ExpiryMinutes { get; set; } = 60; public int ExpiryMinutes { get; set; } = 60;
public int RefreshTokenExpiryDays { get; set; } = 7; public int RefreshTokenExpiryDays { get; set; } = 7;
/// <summary>
/// SameSite-modus voor de refreshToken cookie: Strict | Lax | None.
/// Gebruik None in ontwikkelomgevingen waar frontend en backend
/// op verschillende poorten draaien (vereist Secure=true).
/// </summary>
public string CookieSameSite { get; set; } = "Strict";
} }
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using SlpModularCms.Core.Identity.Models; using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Core.Identity.Services; using SlpModularCms.Core.Identity.Services;
@@ -12,10 +13,12 @@ namespace SlpModularCms.Modules.Identity.Controllers;
public class AuthController : ControllerBase public class AuthController : ControllerBase
{ {
private readonly IAuthService _authService; private readonly IAuthService _authService;
private readonly JwtSettings _jwtSettings;
public AuthController(IAuthService authService) public AuthController(IAuthService authService, IOptions<JwtSettings> jwtSettings)
{ {
_authService = authService; _authService = authService;
_jwtSettings = jwtSettings.Value;
} }
[HttpPost("login")] [HttpPost("login")]
@@ -65,11 +68,15 @@ public class AuthController : ControllerBase
private CookieOptions GetCookieOptions() private CookieOptions GetCookieOptions()
{ {
var sameSite = Enum.TryParse<SameSiteMode>(_jwtSettings.CookieSameSite, ignoreCase: true, out var parsed)
? parsed
: SameSiteMode.Strict;
return new CookieOptions return new CookieOptions
{ {
HttpOnly = true, HttpOnly = true,
Secure = Request.IsHttps, Secure = Request.IsHttps || sameSite == SameSiteMode.None,
SameSite = SameSiteMode.Strict, SameSite = sameSite,
Path = "/api/v1/auth", Path = "/api/v1/auth",
Expires = DateTimeOffset.UtcNow.AddDays(7) Expires = DateTimeOffset.UtcNow.AddDays(7)
}; };