Skip to content

Commit b2720fc

Browse files
committed
Security: re-issue JWT at exchange, CSRF-bind link codes, uniform errors
- GitHubLogin requires authentication for mode=link and stores caller userId in OAuth state for CSRF protection - GitHubCallback reads mode only from tamper-proof OAuth state, never from query string - ExchangeCode re-issues fresh JWT at exchange time instead of reading stored token from DB (no plaintext JWT in database) - LinkGitHub verifies link code was initiated by the same user who is exchanging it (CSRF protection) - All failure paths use uniform error messages to prevent timing side-channel enumeration Addresses findings #1, #3, #5, #7, #8 (CRITICAL/HIGH/MEDIUM).
1 parent 2232bb1 commit b2720fc

File tree

1 file changed

+64
-44
lines changed

1 file changed

+64
-44
lines changed

backend/src/Taskdeck.Api/Controllers/AuthController.cs

Lines changed: 64 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)