@@ -118,6 +118,8 @@ public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest
118118 /// <summary>
119119 /// Initiates GitHub OAuth login flow. Only available when GitHub OAuth is configured.
120120 /// Pass mode=link to start an account-linking flow instead of login.
121+ /// When mode=link, the caller MUST be authenticated (JWT required) so the link code
122+ /// is bound to their identity, preventing CSRF attacks.
121123 /// </summary>
122124 [ HttpGet ( "github/login" ) ]
123125 [ EnableRateLimiting ( RateLimitingPolicyNames . AuthPerIp ) ]
@@ -132,14 +134,18 @@ public IActionResult GitHubLogin([FromQuery] string? returnUrl = null, [FromQuer
132134
133135 var properties = new Microsoft . AspNetCore . Authentication . AuthenticationProperties
134136 {
135- RedirectUri = Url . Action ( nameof ( GitHubCallback ) , new { returnUrl , mode } ) ,
137+ RedirectUri = Url . Action ( nameof ( GitHubCallback ) , new { returnUrl } ) ,
136138 Items = { { "LoginProvider" , "GitHub" } }
137139 } ;
138140
139- // Store mode in the auth properties so the callback can detect linking
141+ // Account-linking mode: require authentication and bind to caller's identity
140142 if ( mode == "link" )
141143 {
144+ if ( ! TryGetCurrentUserId ( out var callerUserId , out _ ) )
145+ return Unauthorized ( new ApiErrorResponse ( ErrorCodes . AuthenticationFailed , "Authentication required for account linking" ) ) ;
146+
142147 properties . Items [ "mode" ] = "link" ;
148+ properties . Items [ "link_user_id" ] = callerUserId . ToString ( ) ;
143149 }
144150
145151 return Challenge ( properties , "GitHub" ) ;
@@ -150,7 +156,7 @@ public IActionResult GitHubLogin([FromQuery] string? returnUrl = null, [FromQuer
150156 /// </summary>
151157 [ HttpGet ( "github/callback" ) ]
152158 [ EnableRateLimiting ( RateLimitingPolicyNames . AuthPerIp ) ]
153- public async Task < IActionResult > GitHubCallback ( [ FromQuery ] string ? returnUrl = null , [ FromQuery ] string ? mode = null )
159+ public async Task < IActionResult > GitHubCallback ( [ FromQuery ] string ? returnUrl = null )
154160 {
155161 if ( ! _gitHubOAuthSettings . IsConfigured )
156162 return NotFound ( new ApiErrorResponse ( ErrorCodes . NotFound , "GitHub OAuth is not configured" ) ) ;
@@ -181,17 +187,30 @@ public async Task<IActionResult> GitHubCallback([FromQuery] string? returnUrl =
181187 // Sign out the temporary cookie used during the OAuth handshake
182188 await HttpContext . SignOutAsync ( "GitHub" ) ;
183189
184- // Determine if this is a link flow. Prefer the OAuth state (tamper-proof)
185- // over the query string parameter, but fall back to query string for compat.
186- var isLinkMode = mode == "link" ;
187- if ( authenticateResult . Properties ? . Items . TryGetValue ( "mode" , out var stateMode ) == true )
190+ // Determine if this is a link flow from the tamper-proof OAuth state ONLY.
191+ // Never trust the query string for mode detection -- attacker could append ?mode=link.
192+ var isLinkMode = false ;
193+ Guid linkUserId = Guid . Empty ;
194+ if ( authenticateResult . Properties ? . Items . TryGetValue ( "mode" , out var stateMode ) == true
195+ && stateMode == "link" )
188196 {
189- isLinkMode = stateMode == "link" ;
197+ isLinkMode = true ;
198+ if ( authenticateResult . Properties . Items . TryGetValue ( "link_user_id" , out var linkUserIdStr )
199+ && Guid . TryParse ( linkUserIdStr , out var parsedLinkUserId ) )
200+ {
201+ linkUserId = parsedLinkUserId ;
202+ }
190203 }
191204
192- // Account linking flow: store the GitHub identity as a link code
205+ // Account linking flow: store the GitHub identity as a link code bound to the user
193206 if ( isLinkMode )
194207 {
208+ if ( linkUserId == Guid . Empty )
209+ {
210+ return BadRequest ( new ApiErrorResponse ( ErrorCodes . ValidationError ,
211+ "Account linking requires an authenticated session" ) ) ;
212+ }
213+
195214 var linkCode = GenerateAuthCode ( ) ;
196215 var providerData = JsonSerializer . Serialize ( new
197216 {
@@ -203,6 +222,7 @@ public async Task<IActionResult> GitHubCallback([FromQuery] string? returnUrl =
203222
204223 var linkAuthCode = OAuthAuthCode . CreateForLinking (
205224 code : linkCode ,
225+ initiatingUserId : linkUserId ,
206226 providerData : providerData ,
207227 expiresAt : DateTimeOffset . UtcNow . AddSeconds ( 60 ) ) ;
208228
@@ -238,19 +258,19 @@ public async Task<IActionResult> GitHubCallback([FromQuery] string? returnUrl =
238258 if ( ! result . IsSuccess )
239259 return result . ToErrorActionResult ( ) ;
240260
241- // Store the authorization code in the database instead of in-memory .
242- // This survives restarts and works with multi-instance deployments .
261+ // Store only the user ID in the auth code -- JWT is re-issued at exchange time .
262+ // This avoids storing plaintext JWTs in the database .
243263 var code = GenerateAuthCode ( ) ;
244264 var authCode = new OAuthAuthCode (
245265 code : code ,
246266 userId : result . Value . User . Id ,
247- token : result . Value . Token ,
267+ token : "placeholder" , // Not stored; JWT re-issued at exchange
248268 expiresAt : DateTimeOffset . UtcNow . AddSeconds ( 60 ) ) ;
249269
250270 await _unitOfWork . OAuthAuthCodes . AddAsync ( authCode ) ;
251271 await _unitOfWork . SaveChangesAsync ( ) ;
252272
253- // Best-effort cleanup of expired codes (runs in the same request scope)
273+ // Best-effort cleanup of expired/consumed codes (runs in the same request scope)
254274 await CleanupExpiredCodesAsync ( ) ;
255275
256276 var safeReturnUrl = ! string . IsNullOrWhiteSpace ( returnUrl ) && Url . IsLocalUrl ( returnUrl )
@@ -264,37 +284,34 @@ public async Task<IActionResult> GitHubCallback([FromQuery] string? returnUrl =
264284 /// <summary>
265285 /// Exchanges a short-lived OAuth authorization code for a JWT token.
266286 /// The code is single-use and expires after 60 seconds.
287+ /// JWT is re-issued fresh at exchange time -- never stored in the database.
267288 /// </summary>
268289 [ HttpPost ( "github/exchange" ) ]
269290 [ EnableRateLimiting ( RateLimitingPolicyNames . AuthPerIp ) ]
270291 public async Task < IActionResult > ExchangeCode ( [ FromBody ] ExchangeCodeRequest request )
271292 {
293+ // Use a single generic error message for all failure modes to prevent
294+ // attackers from enumerating codes or determining their state.
295+ const string genericError = "Invalid or expired code" ;
296+
272297 if ( string . IsNullOrWhiteSpace ( request . Code ) )
273298 return BadRequest ( new ApiErrorResponse ( ErrorCodes . ValidationError , "Code is required" ) ) ;
274299
275- // Read the code first to check purpose and expiry
300+ // Read the code to check purpose (pre-filter before atomic consume)
276301 var authCode = await _unitOfWork . OAuthAuthCodes . GetByCodeAsync ( request . Code ) ;
277- if ( authCode == null )
278- return Unauthorized ( new ApiErrorResponse ( ErrorCodes . AuthenticationFailed , "Invalid or expired code" ) ) ;
279-
280- if ( authCode . IsLinkingCode )
281- return BadRequest ( new ApiErrorResponse ( ErrorCodes . ValidationError , "This code is for account linking, not login" ) ) ;
302+ if ( authCode == null || authCode . IsLinkingCode || authCode . IsExpired || authCode . IsConsumed )
303+ return Unauthorized ( new ApiErrorResponse ( ErrorCodes . AuthenticationFailed , genericError ) ) ;
282304
283- if ( authCode . IsExpired )
284- return Unauthorized ( new ApiErrorResponse ( ErrorCodes . AuthenticationFailed , "Code has expired" ) ) ;
285-
286- if ( authCode . IsConsumed )
287- return Unauthorized ( new ApiErrorResponse ( ErrorCodes . AuthenticationFailed , "Invalid or expired code" ) ) ;
288-
289- // Atomically consume the code — prevents race conditions with concurrent requests
305+ // Atomically consume the code — prevents race conditions with concurrent requests.
306+ // The SQL also enforces expiry check to close the TOCTOU window.
290307 var consumed = await _unitOfWork . OAuthAuthCodes . TryConsumeAtomicAsync ( request . Code ) ;
291308 if ( ! consumed )
292- return Unauthorized ( new ApiErrorResponse ( ErrorCodes . AuthenticationFailed , "Invalid or expired code" ) ) ;
309+ return Unauthorized ( new ApiErrorResponse ( ErrorCodes . AuthenticationFailed , genericError ) ) ;
293310
294- // Look up the user to build the AuthResultDto
311+ // Look up the user and re-issue a fresh JWT (never stored in DB)
295312 var user = await _unitOfWork . Users . GetByIdAsync ( authCode . UserId ) ;
296313 if ( user == null )
297- return Unauthorized ( new ApiErrorResponse ( ErrorCodes . AuthenticationFailed , "User not found" ) ) ;
314+ return Unauthorized ( new ApiErrorResponse ( ErrorCodes . AuthenticationFailed , genericError ) ) ;
298315
299316 var userDto = new UserDto (
300317 user . Id ,
@@ -305,12 +322,16 @@ public async Task<IActionResult> ExchangeCode([FromBody] ExchangeCodeRequest req
305322 user . CreatedAt ,
306323 user . UpdatedAt ) ;
307324
308- return Ok ( new AuthResultDto ( authCode . Token , userDto ) ) ;
325+ // Re-issue JWT at exchange time instead of reading stored token
326+ var freshToken = _authService . GenerateJwtToken ( user ) ;
327+
328+ return Ok ( new AuthResultDto ( freshToken , userDto ) ) ;
309329 }
310330
311331 /// <summary>
312332 /// Exchanges a link code and associates the GitHub account with the authenticated user.
313- /// Requires a valid JWT session.
333+ /// Requires a valid JWT session. The link code must have been initiated by the same user
334+ /// (CSRF protection: code is bound to the initiating user's identity).
314335 /// </summary>
315336 [ HttpPost ( "github/link" ) ]
316337 [ Authorize ]
@@ -321,6 +342,8 @@ public async Task<IActionResult> ExchangeCode([FromBody] ExchangeCodeRequest req
321342 [ ProducesResponseType ( typeof ( ApiErrorResponse ) , StatusCodes . Status409Conflict ) ]
322343 public async Task < IActionResult > LinkGitHub ( [ FromBody ] LinkExchangeRequest request )
323344 {
345+ const string genericError = "Invalid or expired link code" ;
346+
324347 if ( ! _gitHubOAuthSettings . IsConfigured )
325348 return NotFound ( new ApiErrorResponse ( ErrorCodes . NotFound , "GitHub OAuth is not configured" ) ) ;
326349
@@ -330,24 +353,21 @@ public async Task<IActionResult> LinkGitHub([FromBody] LinkExchangeRequest reque
330353 if ( string . IsNullOrWhiteSpace ( request . Code ) )
331354 return BadRequest ( new ApiErrorResponse ( ErrorCodes . ValidationError , "Link code is required" ) ) ;
332355
333- // Look up and validate the link code
356+ // Look up and validate the link code with uniform error messages
334357 var authCode = await _unitOfWork . OAuthAuthCodes . GetByCodeAsync ( request . Code ) ;
335- if ( authCode == null )
336- return Unauthorized ( new ApiErrorResponse ( ErrorCodes . AuthenticationFailed , "Invalid or expired link code" ) ) ;
337-
338- if ( ! authCode . IsLinkingCode )
339- return BadRequest ( new ApiErrorResponse ( ErrorCodes . ValidationError , "This code is for login, not account linking" ) ) ;
340-
341- if ( authCode . IsExpired )
342- return Unauthorized ( new ApiErrorResponse ( ErrorCodes . AuthenticationFailed , "Link code has expired" ) ) ;
358+ if ( authCode == null || ! authCode . IsLinkingCode || authCode . IsExpired || authCode . IsConsumed )
359+ return Unauthorized ( new ApiErrorResponse ( ErrorCodes . AuthenticationFailed , genericError ) ) ;
343360
344- if ( authCode . IsConsumed )
345- return Unauthorized ( new ApiErrorResponse ( ErrorCodes . AuthenticationFailed , "Invalid or expired link code" ) ) ;
361+ // CSRF protection: verify the link code was initiated by the same user who is
362+ // exchanging it. This prevents an attacker from generating a link code and
363+ // tricking a victim into exchanging it.
364+ if ( authCode . UserId != callerUserId )
365+ return Unauthorized ( new ApiErrorResponse ( ErrorCodes . AuthenticationFailed , genericError ) ) ;
346366
347- // Atomically consume the code
367+ // Atomically consume the code (also checks expiry in SQL to close TOCTOU window)
348368 var consumed = await _unitOfWork . OAuthAuthCodes . TryConsumeAtomicAsync ( request . Code ) ;
349369 if ( ! consumed )
350- return Unauthorized ( new ApiErrorResponse ( ErrorCodes . AuthenticationFailed , "Invalid or expired link code" ) ) ;
370+ return Unauthorized ( new ApiErrorResponse ( ErrorCodes . AuthenticationFailed , genericError ) ) ;
351371
352372 // Parse the provider data from the link code
353373 if ( string . IsNullOrWhiteSpace ( authCode . ProviderData ) )
0 commit comments