Task 11: Smoke test fixes (all 5 scenarios pass)

TokenService.GenerateRefreshToken():
  - Switched to URL-safe Base64 (RFC 4648 §5): +→-, /→_, no = padding.
  - Characters are unreserved per RFC 6265, so Response.Cookies.Append
    does NOT percent-encode the value.  Request.Cookies reads back exact value.

AuthController:
  - CookieOptions.Secure = !env.IsDevelopment()
    Plain HTTP in local dev works; HTTPS-only in staging/production.
  - Inject IWebHostEnvironment for environment-aware Secure flag.

TokenServiceTests:
  - Updated GenerateRefreshToken test: 86-char URL-safe Base64 instead
    of 64-byte standard Base64.  16/16 tests pass.

Smoke test results (http://localhost:5209):
  1. POST /api/auth/login       → 200 + rolac_rt cookie + JWT
  2. POST /api/auth/refresh     → 200 + new token (rotation)
  3. POST /api/auth/logout      → 204 + cookie cleared
  4. Refresh with revoked token → 401
  5. Wrong password             → 401

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Chen
2026-05-26 19:28:20 -07:00
parent ef0098d5cc
commit 2aa095c158
3 changed files with 32 additions and 11 deletions
+16 -7
View File
@@ -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
// -------------------------------------------------------------------------
/// <summary>
/// <c>Secure</c> is set to <c>true</c> 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.
/// </summary>
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",
});
+9 -1
View File
@@ -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)