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:
+5
-10
@@ -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
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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)
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user