@@ -17,15 +17,10 @@ public class AuthController : ControllerBase
1717 private readonly IAuthService _auth ;
1818 private readonly ICurrentUser _currentUser ;
1919 private readonly ILoginLockoutService _lockout ;
20- private readonly IRefreshTokenService _refresh ; // ← NEW (R4)
21- private readonly IJwtTokenService _jwt ; // ← NEW (R4)
20+ private readonly IRefreshTokenService _refresh ;
21+ private readonly IJwtTokenService _jwt ;
2222
2323 /// <summary>Creates a new <see cref="AuthController"/>.</summary>
24- /// <param name="auth">Authentication service.</param>
25- /// <param name="currentUser">The current authenticated user.</param>
26- /// <param name="lockout">Login lockout (brute-force) service.</param>
27- /// <param name="refresh">Refresh-token service.</param>
28- /// <param name="jwt">JWT access-token service.</param>
2924 public AuthController (
3025 IAuthService auth ,
3126 ICurrentUser currentUser ,
@@ -40,13 +35,7 @@ public AuthController(
4035 _jwt = jwt ;
4136 }
4237
43- /// <summary>Authenticates a user and issues a JWT access token plus a rotating refresh token.</summary>
44- /// <param name="req">Login request with email and password.</param>
45- /// <param name="ct">Cancellation token.</param>
46- /// <returns>The access token, refresh token, and the user's authorized sites.</returns>
47- /// <response code="200">Login succeeded.</response>
48- /// <response code="401">Email or password is incorrect.</response>
49- /// <response code="429">Too many attempts — rate limited or account locked.</response>
38+ /// <summary>Authenticates a user and issues a JWT access token; the refresh token is set as an HttpOnly cookie.</summary>
5039 [ AllowAnonymous ]
5140 [ HttpPost ( "login" ) ]
5241 [ EnableRateLimiting ( "login" ) ]
@@ -73,56 +62,53 @@ public async Task<IActionResult> Login([FromBody] LoginRequest req, Cancellation
7362
7463 await _lockout . ResetAsync ( email , ct ) ;
7564
76- // NEW (R4): mint a rotating refresh token alongside the 15-min access token .
65+ // Refresh token now lives ONLY in an HttpOnly cookie — never in the JSON body .
7766 var clientIp = HttpContext . Connection . RemoteIpAddress ? . ToString ( ) ;
78- var ( raw , _ ) = await _refresh . IssueAsync ( result . User . Id , clientIp , ct ) ;
79- result = result with { RefreshToken = raw } ;
67+ var ( raw , refreshExpiresAt ) = await _refresh . IssueAsync ( result . User . Id , clientIp , ct ) ;
68+ SetRefreshCookie ( raw , refreshExpiresAt ) ;
8069
8170 return Ok ( ApiResponse < LoginResponse > . Ok ( result ) ) ;
8271 }
8372
84- /// <summary>Exchanges a valid refresh token for a fresh access token and a rotated refresh token.</summary>
85- /// <param name="req">Request containing the current refresh token.</param>
86- /// <param name="ct">Cancellation token.</param>
87- /// <returns>A new access token and rotated refresh token.</returns>
88- /// <response code="200">New tokens issued.</response>
89- /// <response code="401">Refresh token is invalid or expired.</response>
90- // NEW (R4): exchange a valid refresh token for a fresh access token + rotated refresh token.
73+ /// <summary>Exchanges the refresh-token cookie for a fresh access token and rotates the cookie.</summary>
9174 [ AllowAnonymous ]
9275 [ HttpPost ( "refresh" ) ]
93- public async Task < IActionResult > Refresh ( [ FromBody ] RefreshRequest req , CancellationToken ct )
76+ public async Task < IActionResult > Refresh ( CancellationToken ct )
9477 {
78+ var presented = Request . Cookies [ "refresh_token" ] ;
79+ if ( string . IsNullOrEmpty ( presented ) )
80+ return Unauthorized ( ApiResponse < object > . Fail (
81+ "INVALID_REFRESH_TOKEN" , "Refresh token is missing." ) ) ;
82+
9583 var ip = HttpContext . Connection . RemoteIpAddress ? . ToString ( ) ;
96- var rotated = await _refresh . ValidateAndRotateAsync ( req . RefreshToken , ip , ct ) ;
84+ var rotated = await _refresh . ValidateAndRotateAsync ( presented , ip , ct ) ;
9785 if ( rotated is null )
86+ {
87+ DeleteRefreshCookie ( ) ; // clear the bad cookie
9888 return Unauthorized ( ApiResponse < object > . Fail (
9989 "INVALID_REFRESH_TOKEN" , "Refresh token is invalid or expired." ) ) ;
90+ }
10091
101- var ( user , newRaw , _) = rotated . Value ;
92+ var ( user , newRaw , newExpiresAt ) = rotated . Value ;
93+ SetRefreshCookie ( newRaw , newExpiresAt ) ; // rotate the cookie
10294 var accessToken = _jwt . CreateToken ( user ) ;
103- return Ok ( ApiResponse < RefreshResponse > . Ok ( new RefreshResponse ( accessToken , newRaw ) ) ) ;
95+ return Ok ( ApiResponse < RefreshResponse > . Ok ( new RefreshResponse ( accessToken ) ) ) ;
10496 }
10597
106- /// <summary>Revokes a refresh token (logout). Anonymous so an expired access token can't block cleanup.</summary>
107- /// <param name="req">Request containing the refresh token to revoke.</param>
108- /// <param name="ct">Cancellation token.</param>
109- /// <returns>Confirmation that the token was revoked (idempotent).</returns>
110- /// <response code="200">Token revoked.</response>
111- // NEW (R4): revoke a refresh token (logout). AllowAnonymous so an expired
112- // access token doesn't block the client from cleanly revoking.
98+ /// <summary>Revokes the refresh token (logout) and clears the cookie.</summary>
11399 [ AllowAnonymous ]
114100 [ HttpPost ( "logout" ) ]
115- public async Task < IActionResult > Logout ( [ FromBody ] LogoutRequest req , CancellationToken ct )
101+ public async Task < IActionResult > Logout ( CancellationToken ct )
116102 {
117- await _refresh . RevokeAsync ( req . RefreshToken , ct ) ;
103+ var raw = Request . Cookies [ "refresh_token" ] ;
104+ if ( ! string . IsNullOrEmpty ( raw ) )
105+ await _refresh . RevokeAsync ( raw , ct ) ;
106+
107+ DeleteRefreshCookie ( ) ;
118108 return Ok ( ApiResponse < object > . Ok ( new { logged_out = true } ) ) ;
119109 }
120110
121111 /// <summary>Returns the current authenticated user's profile and authorized sites (session rehydration).</summary>
122- /// <param name="ct">Cancellation token.</param>
123- /// <returns>The current user's profile, or 401 if no valid session.</returns>
124- /// <response code="200">Profile returned.</response>
125- /// <response code="401">No valid session.</response>
126112 [ Authorize ]
127113 [ HttpGet ( "me" ) ]
128114 public async Task < IActionResult > Me ( CancellationToken ct )
@@ -133,19 +119,37 @@ public async Task<IActionResult> Me(CancellationToken ct)
133119 }
134120
135121 /// <summary>Changes the current user's password.</summary>
136- /// <param name="req">Current and new password.</param>
137- /// <param name="ct">Cancellation token.</param>
138- /// <returns>Confirmation, or a validation error.</returns>
139- /// <response code="200">Password changed.</response>
140- /// <response code="400">Current password is incorrect.</response>
141122 [ Authorize ]
142123 [ HttpPost ( "change-password" ) ]
143124 public async Task < IActionResult > ChangePassword ( [ FromBody ] ChangePasswordRequest req , CancellationToken ct )
144125 {
145- var ok = await _auth . ChangePasswordAsync ( _currentUser . UserId , req , ct ) ;
146- if ( ! ok )
147- return BadRequest ( ApiResponse < object > . Fail (
148- "INVALID_PASSWORD" , "Current password is incorrect." ) ) ;
126+ var error = await _auth . ChangePasswordAsync ( _currentUser . UserId , req , ct ) ;
127+ if ( error is not null )
128+ return BadRequest ( ApiResponse < object > . Fail ( "INVALID_PASSWORD" , error ) ) ;
149129 return Ok ( ApiResponse < object > . Ok ( new { changed = true } ) ) ;
150130 }
131+
132+ // ── refresh-token cookie helpers ────────────────────────────────────────
133+ private void SetRefreshCookie ( string rawToken , DateTime expiresAt )
134+ {
135+ Response . Cookies . Append ( "refresh_token" , rawToken , new CookieOptions
136+ {
137+ HttpOnly = true ,
138+ Secure = true ,
139+ SameSite = SameSiteMode . Strict ,
140+ Expires = expiresAt ,
141+ Path = "/api/v1/auth"
142+ } ) ;
143+ }
144+
145+ private void DeleteRefreshCookie ( )
146+ {
147+ Response . Cookies . Delete ( "refresh_token" , new CookieOptions
148+ {
149+ HttpOnly = true ,
150+ Secure = true ,
151+ SameSite = SameSiteMode . Strict ,
152+ Path = "/api/v1/auth"
153+ } ) ;
154+ }
151155}
0 commit comments