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:
@@ -97,13 +97,17 @@ public class TokenServiceTests
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GenerateRefreshToken_Returns64ByteBase64String()
|
public void GenerateRefreshToken_Returns86CharUrlSafeBase64()
|
||||||
{
|
{
|
||||||
var token = _sut.GenerateRefreshToken();
|
var token = _sut.GenerateRefreshToken();
|
||||||
|
|
||||||
Assert.NotEmpty(token);
|
Assert.NotEmpty(token);
|
||||||
var bytes = Convert.FromBase64String(token);
|
// 64 bytes → 88 standard Base64 chars → 86 URL-safe chars (no '==' padding,
|
||||||
Assert.Equal(64, bytes.Length);
|
// '+' → '-', '/' → '_'). 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]
|
[Fact]
|
||||||
|
|||||||
@@ -9,13 +9,17 @@ namespace ROLAC.API.Controllers;
|
|||||||
[Route("api/auth")]
|
[Route("api/auth")]
|
||||||
public class AuthController : ControllerBase
|
public class AuthController : ControllerBase
|
||||||
{
|
{
|
||||||
private const string CookieName = "rolac_rt";
|
private const string CookieName = "rolac_rt";
|
||||||
private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
|
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)
|
public AuthController(IAuthService authService, IWebHostEnvironment env)
|
||||||
=> _authService = authService;
|
{
|
||||||
|
_authService = authService;
|
||||||
|
_env = env;
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// POST /api/auth/login
|
// POST /api/auth/login
|
||||||
@@ -96,11 +100,16 @@ public class AuthController : ControllerBase
|
|||||||
// Private helpers
|
// 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)
|
private void SetRefreshCookie(string rawToken)
|
||||||
=> Response.Cookies.Append(CookieName, rawToken, new CookieOptions
|
=> Response.Cookies.Append(CookieName, rawToken, new CookieOptions
|
||||||
{
|
{
|
||||||
HttpOnly = true,
|
HttpOnly = true,
|
||||||
Secure = true,
|
Secure = !_env.IsDevelopment(),
|
||||||
SameSite = SameSiteMode.Strict,
|
SameSite = SameSiteMode.Strict,
|
||||||
MaxAge = TimeSpan.FromSeconds(CookieMaxAge),
|
MaxAge = TimeSpan.FromSeconds(CookieMaxAge),
|
||||||
Path = "/api/auth",
|
Path = "/api/auth",
|
||||||
@@ -110,7 +119,7 @@ public class AuthController : ControllerBase
|
|||||||
=> Response.Cookies.Delete(CookieName, new CookieOptions
|
=> Response.Cookies.Delete(CookieName, new CookieOptions
|
||||||
{
|
{
|
||||||
HttpOnly = true,
|
HttpOnly = true,
|
||||||
Secure = true,
|
Secure = !_env.IsDevelopment(),
|
||||||
SameSite = SameSiteMode.Strict,
|
SameSite = SameSiteMode.Strict,
|
||||||
Path = "/api/auth",
|
Path = "/api/auth",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,7 +54,15 @@ public class TokenService : ITokenService
|
|||||||
var bytes = new byte[64];
|
var bytes = new byte[64];
|
||||||
using var rng = RandomNumberGenerator.Create();
|
using var rng = RandomNumberGenerator.Create();
|
||||||
rng.GetBytes(bytes);
|
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)
|
public string HashToken(string rawToken)
|
||||||
|
|||||||
Reference in New Issue
Block a user