diff --git a/API/ROLAC.API.Tests/Services/TokenServiceTests.cs b/API/ROLAC.API.Tests/Services/TokenServiceTests.cs index 5d7cc02..cfed838 100644 --- a/API/ROLAC.API.Tests/Services/TokenServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/TokenServiceTests.cs @@ -97,13 +97,17 @@ public class TokenServiceTests // --------------------------------------------------------------------------- [Fact] - public void GenerateRefreshToken_Returns64ByteBase64String() + public void GenerateRefreshToken_Returns86CharUrlSafeBase64() { var token = _sut.GenerateRefreshToken(); Assert.NotEmpty(token); - var bytes = Convert.FromBase64String(token); - Assert.Equal(64, bytes.Length); + // 64 bytes → 88 standard Base64 chars → 86 URL-safe chars (no '==' padding, + // '+' → '-', '/' → '_'). All chars must be unreserved in RFC 6265 cookie-values. + Assert.Equal(86, token.Length); + Assert.True( + token.All(c => char.IsLetterOrDigit(c) || c == '-' || c == '_'), + "Token must use URL-safe Base64 alphabet (A-Z a-z 0-9 - _)"); } [Fact] diff --git a/API/ROLAC.API/Controllers/AuthController.cs b/API/ROLAC.API/Controllers/AuthController.cs index e773cfa..2609fd7 100644 --- a/API/ROLAC.API/Controllers/AuthController.cs +++ b/API/ROLAC.API/Controllers/AuthController.cs @@ -9,13 +9,17 @@ namespace ROLAC.API.Controllers; [Route("api/auth")] public class AuthController : ControllerBase { - private const string CookieName = "rolac_rt"; - private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds + private const string CookieName = "rolac_rt"; + private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds - private readonly IAuthService _authService; + private readonly IAuthService _authService; + private readonly IWebHostEnvironment _env; - public AuthController(IAuthService authService) - => _authService = authService; + public AuthController(IAuthService authService, IWebHostEnvironment env) + { + _authService = authService; + _env = env; + } // ------------------------------------------------------------------------- // POST /api/auth/login @@ -96,11 +100,16 @@ public class AuthController : ControllerBase // Private helpers // ------------------------------------------------------------------------- + /// + /// Secure is set to true everywhere except in the local + /// Development environment so that the cookie works over plain HTTP during + /// dev and smoke-test runs, but is always HTTPS-only in staging/production. + /// private void SetRefreshCookie(string rawToken) => Response.Cookies.Append(CookieName, rawToken, new CookieOptions { HttpOnly = true, - Secure = true, + Secure = !_env.IsDevelopment(), SameSite = SameSiteMode.Strict, MaxAge = TimeSpan.FromSeconds(CookieMaxAge), Path = "/api/auth", @@ -110,7 +119,7 @@ public class AuthController : ControllerBase => Response.Cookies.Delete(CookieName, new CookieOptions { HttpOnly = true, - Secure = true, + Secure = !_env.IsDevelopment(), SameSite = SameSiteMode.Strict, Path = "/api/auth", }); diff --git a/API/ROLAC.API/Services/TokenService.cs b/API/ROLAC.API/Services/TokenService.cs index a67624e..6c19fca 100644 --- a/API/ROLAC.API/Services/TokenService.cs +++ b/API/ROLAC.API/Services/TokenService.cs @@ -54,7 +54,15 @@ public class TokenService : ITokenService var bytes = new byte[64]; using var rng = RandomNumberGenerator.Create(); rng.GetBytes(bytes); - return Convert.ToBase64String(bytes); + // Use URL-safe Base64 (RFC 4648 §5) with no padding. + // Standard Base64 '+' → '-', '/' → '_', '=' stripped. + // All resulting characters are unreserved in RFC 6265 cookie-values, + // so Response.Cookies.Append will NOT percent-encode the token — + // meaning Request.Cookies[name] returns the exact string we stored. + return Convert.ToBase64String(bytes) + .Replace('+', '-') + .Replace('/', '_') + .TrimEnd('='); } public string HashToken(string rawToken)