Skip to content

Commit 599759e

Browse files
authored
Merge pull request #812 from Chris0Jeky/feature/oauth-pkce-account-linking
CLD-03 follow-up: DB-backed auth codes, PKCE, account linking
2 parents ce18308 + 4743422 commit 599759e

33 files changed

+3507
-175
lines changed

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

Lines changed: 245 additions & 25 deletions
Large diffs are not rendered by default.

backend/src/Taskdeck.Api/Extensions/AuthenticationRegistration.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ await context.Response.WriteAsJsonAsync(new ApiErrorResponse(
9898
options.CallbackPath = "/api/auth/github/oauth-redirect";
9999
options.SaveTokens = false;
100100

101+
// PKCE (Proof Key for Code Exchange) — defense-in-depth against
102+
// authorization code interception attacks. GitHub supports PKCE
103+
// and ASP.NET Core 8+ handles code_verifier/code_challenge automatically.
104+
options.UsePkce = true;
105+
101106
options.Scope.Add("read:user");
102107
options.Scope.Add("user:email");
103108

backend/src/Taskdeck.Application/DTOs/UserDtos.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,10 @@ public record ExternalLoginDto(
3737
string Email,
3838
string? DisplayName = null,
3939
string? AvatarUrl = null);
40+
41+
public record LinkedAccountDto(
42+
string Provider,
43+
string ProviderUserId,
44+
string? DisplayName,
45+
string? AvatarUrl,
46+
DateTimeOffset LinkedAt);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using Taskdeck.Domain.Entities;
2+
3+
namespace Taskdeck.Application.Interfaces;
4+
5+
public interface IOAuthAuthCodeRepository : IRepository<OAuthAuthCode>
6+
{
7+
/// <summary>
8+
/// Finds an auth code by its code string. Returns null if not found.
9+
/// </summary>
10+
Task<OAuthAuthCode?> GetByCodeAsync(string code, CancellationToken cancellationToken = default);
11+
12+
/// <summary>
13+
/// Atomically consumes an auth code by setting IsConsumed = true where IsConsumed = false.
14+
/// Returns true if the code was consumed (affected 1 row), false if already consumed or not found.
15+
/// This provides single-use semantics safe for concurrent requests.
16+
/// </summary>
17+
Task<bool> TryConsumeAtomicAsync(string code, CancellationToken cancellationToken = default);
18+
19+
/// <summary>
20+
/// Deletes all auth codes that have expired before the specified cutoff time.
21+
/// Returns the number of codes removed.
22+
/// </summary>
23+
Task<int> DeleteExpiredAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default);
24+
}

backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ public interface IUnitOfWork
2727
IKnowledgeDocumentRepository KnowledgeDocuments { get; }
2828
IKnowledgeChunkRepository KnowledgeChunks { get; }
2929
IExternalLoginRepository ExternalLogins { get; }
30+
IOAuthAuthCodeRepository OAuthAuthCodes { get; }
3031
IApiKeyRepository ApiKeys { get; }
3132

3233
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);

backend/src/Taskdeck.Application/Services/AuthenticationService.cs

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,83 @@ public async Task<Result<AuthResultDto>> ExternalLoginAsync(ExternalLoginDto dto
209209
}
210210
}
211211

212+
public async Task<Result<LinkedAccountDto>> CompleteAccountLinkAsync(Guid userId, string provider, string providerUserId, string? displayName, string? avatarUrl)
213+
{
214+
try
215+
{
216+
if (userId == Guid.Empty)
217+
return Result.Failure<LinkedAccountDto>(ErrorCodes.ValidationError, "User ID is required");
218+
219+
var user = await _unitOfWork.Users.GetByIdAsync(userId);
220+
if (user == null)
221+
return Result.Failure<LinkedAccountDto>(ErrorCodes.NotFound, "User not found");
222+
223+
if (!user.IsActive)
224+
return Result.Failure<LinkedAccountDto>(ErrorCodes.Forbidden, "User account is inactive");
225+
226+
// Check if this provider+userId combo is already linked to a different user
227+
var existingLogin = await _unitOfWork.ExternalLogins.GetByProviderAsync(provider, providerUserId);
228+
if (existingLogin != null)
229+
{
230+
if (existingLogin.UserId == userId)
231+
return Result.Failure<LinkedAccountDto>(ErrorCodes.Conflict, $"This {provider} account is already linked to your account");
232+
233+
return Result.Failure<LinkedAccountDto>(ErrorCodes.Conflict, $"This {provider} account is already linked to a different user");
234+
}
235+
236+
// Check if user already has a linked account for this provider
237+
var userLogins = await _unitOfWork.ExternalLogins.GetByUserIdAsync(userId);
238+
if (userLogins.Any(l => l.Provider == provider))
239+
return Result.Failure<LinkedAccountDto>(ErrorCodes.Conflict, $"Your account is already linked to a {provider} account");
240+
241+
var newLogin = new ExternalLogin(userId, provider, providerUserId, displayName, avatarUrl);
242+
await _unitOfWork.ExternalLogins.AddAsync(newLogin);
243+
await _unitOfWork.SaveChangesAsync();
244+
245+
return Result.Success(new LinkedAccountDto(
246+
newLogin.Provider,
247+
newLogin.ProviderUserId,
248+
newLogin.ProviderDisplayName,
249+
newLogin.AvatarUrl,
250+
newLogin.CreatedAt));
251+
}
252+
catch (DomainException ex)
253+
{
254+
return Result.Failure<LinkedAccountDto>(ex.ErrorCode, ex.Message);
255+
}
256+
catch (Exception)
257+
{
258+
return Result.Failure<LinkedAccountDto>(ErrorCodes.UnexpectedError, "Account linking failed due to an unexpected error");
259+
}
260+
}
261+
262+
public async Task<Result> UnlinkExternalLoginAsync(Guid userId, string provider)
263+
{
264+
try
265+
{
266+
if (userId == Guid.Empty)
267+
return Result.Failure(ErrorCodes.ValidationError, "User ID is required");
268+
269+
var user = await _unitOfWork.Users.GetByIdAsync(userId);
270+
if (user == null)
271+
return Result.Failure(ErrorCodes.NotFound, "User not found");
272+
273+
var logins = await _unitOfWork.ExternalLogins.GetByUserIdAsync(userId);
274+
var loginToRemove = logins.FirstOrDefault(l => l.Provider == provider);
275+
if (loginToRemove == null)
276+
return Result.Failure(ErrorCodes.NotFound, $"No {provider} account is linked");
277+
278+
await _unitOfWork.ExternalLogins.DeleteAsync(loginToRemove);
279+
await _unitOfWork.SaveChangesAsync();
280+
281+
return Result.Success();
282+
}
283+
catch (DomainException ex)
284+
{
285+
return Result.Failure(ex.ErrorCode, ex.Message);
286+
}
287+
}
288+
212289
public async Task<Result> ChangePasswordAsync(Guid userId, string currentPassword, string newPassword)
213290
{
214291
try
@@ -290,7 +367,7 @@ public async Task<Result<UserDto>> ValidateTokenAsync(string token)
290367
}
291368
}
292369

293-
private string GenerateJwtToken(User user)
370+
public string GenerateJwtToken(User user)
294371
{
295372
if (!TryValidateJwtSettings(out var jwtValidationError))
296373
throw new DomainException(ErrorCodes.UnexpectedError, jwtValidationError);

backend/src/Taskdeck.Application/Services/IAuthenticationService.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,6 @@ public interface IAuthenticationService
1414
Task<Result> ChangePasswordAsync(Guid userId, string currentPassword, string newPassword);
1515
Task<Result<UserDto>> ValidateTokenAsync(string token);
1616
Task<Result<AuthResultDto>> ExternalLoginAsync(ExternalLoginDto dto);
17+
Task<Result<LinkedAccountDto>> CompleteAccountLinkAsync(Guid userId, string provider, string providerUserId, string? displayName, string? avatarUrl);
18+
Task<Result> UnlinkExternalLoginAsync(Guid userId, string provider);
1719
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
using Taskdeck.Domain.Common;
2+
using Taskdeck.Domain.Exceptions;
3+
4+
namespace Taskdeck.Domain.Entities;
5+
6+
/// <summary>
7+
/// A short-lived, single-use authorization code issued after OAuth callback.
8+
/// Stored in the database to survive restarts and support multi-instance deployments.
9+
/// Supports both login (Token populated) and account-linking (ProviderData populated) flows.
10+
/// </summary>
11+
public class OAuthAuthCode : Entity
12+
{
13+
private string _code = string.Empty;
14+
15+
public string Code
16+
{
17+
get => _code;
18+
private set
19+
{
20+
if (string.IsNullOrWhiteSpace(value))
21+
throw new DomainException(ErrorCodes.ValidationError, "Auth code cannot be empty");
22+
23+
if (value.Length > 512)
24+
throw new DomainException(ErrorCodes.ValidationError, "Auth code cannot exceed 512 characters");
25+
26+
_code = value;
27+
}
28+
}
29+
30+
/// <summary>
31+
/// The user ID this code authenticates (login flow) or Guid.Empty (link flow).
32+
/// </summary>
33+
public Guid UserId { get; private set; }
34+
35+
/// <summary>
36+
/// Legacy field kept for schema compatibility. No longer populated with real JWTs --
37+
/// tokens are re-issued at exchange time from the stored UserId.
38+
/// </summary>
39+
public string Token { get; private set; } = string.Empty;
40+
41+
/// <summary>
42+
/// The purpose of this code: "login" or "link".
43+
/// </summary>
44+
public string Purpose { get; private set; } = "login";
45+
46+
/// <summary>
47+
/// JSON-serialized provider identity data for account linking flows.
48+
/// Contains provider, providerUserId, displayName, avatarUrl.
49+
/// </summary>
50+
public string? ProviderData { get; private set; }
51+
52+
/// <summary>
53+
/// When this code expires and can no longer be exchanged.
54+
/// </summary>
55+
public DateTimeOffset ExpiresAt { get; private set; }
56+
57+
/// <summary>
58+
/// Whether this code has been consumed (exchanged for a token).
59+
/// </summary>
60+
public bool IsConsumed { get; private set; }
61+
62+
/// <summary>
63+
/// When the code was consumed, if applicable.
64+
/// </summary>
65+
public DateTimeOffset? ConsumedAt { get; private set; }
66+
67+
private OAuthAuthCode() : base() { }
68+
69+
/// <summary>
70+
/// Creates an auth code for the login flow (token exchange).
71+
/// Token parameter is accepted for backward compatibility but is no longer stored;
72+
/// JWTs are re-issued at exchange time from the UserId.
73+
/// </summary>
74+
public OAuthAuthCode(string code, Guid userId, string token, DateTimeOffset expiresAt)
75+
: base()
76+
{
77+
if (userId == Guid.Empty)
78+
throw new DomainException(ErrorCodes.ValidationError, "User ID cannot be empty");
79+
80+
if (string.IsNullOrWhiteSpace(token))
81+
throw new DomainException(ErrorCodes.ValidationError, "Token cannot be empty");
82+
83+
if (expiresAt <= DateTimeOffset.UtcNow)
84+
throw new DomainException(ErrorCodes.ValidationError, "Expiry must be in the future");
85+
86+
Code = code;
87+
UserId = userId;
88+
Token = string.Empty; // Never store actual JWT in DB — re-issue at exchange time
89+
Purpose = "login";
90+
ExpiresAt = expiresAt;
91+
}
92+
93+
/// <summary>
94+
/// Creates an auth code for the account linking flow (provider identity exchange).
95+
/// The initiatingUserId binds this code to the user who started the link flow,
96+
/// preventing CSRF attacks where an attacker's GitHub is linked to a victim's account.
97+
/// </summary>
98+
public static OAuthAuthCode CreateForLinking(string code, Guid initiatingUserId, string providerData, DateTimeOffset expiresAt)
99+
{
100+
if (initiatingUserId == Guid.Empty)
101+
throw new DomainException(ErrorCodes.ValidationError, "Initiating user ID is required for linking");
102+
103+
if (string.IsNullOrWhiteSpace(providerData))
104+
throw new DomainException(ErrorCodes.ValidationError, "Provider data cannot be empty for linking");
105+
106+
if (expiresAt <= DateTimeOffset.UtcNow)
107+
throw new DomainException(ErrorCodes.ValidationError, "Expiry must be in the future");
108+
109+
var entity = new OAuthAuthCode
110+
{
111+
Code = code,
112+
UserId = initiatingUserId,
113+
Token = string.Empty,
114+
Purpose = "link",
115+
ProviderData = providerData,
116+
ExpiresAt = expiresAt
117+
};
118+
return entity;
119+
}
120+
121+
/// <summary>
122+
/// Returns true if this code has expired.
123+
/// </summary>
124+
public bool IsExpired => DateTimeOffset.UtcNow > ExpiresAt;
125+
126+
/// <summary>
127+
/// Returns true if this is a linking code (not a login code).
128+
/// </summary>
129+
public bool IsLinkingCode => Purpose == "link";
130+
131+
/// <summary>
132+
/// Attempts to consume this code. Returns false if already consumed or expired.
133+
/// </summary>
134+
public bool TryConsume()
135+
{
136+
if (IsConsumed || IsExpired)
137+
return false;
138+
139+
IsConsumed = true;
140+
ConsumedAt = DateTimeOffset.UtcNow;
141+
Touch();
142+
return true;
143+
}
144+
}

backend/src/Taskdeck.Infrastructure/DependencyInjection.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
4343
services.AddScoped<IKnowledgeDocumentRepository, KnowledgeDocumentRepository>();
4444
services.AddScoped<IKnowledgeChunkRepository, KnowledgeChunkRepository>();
4545
services.AddScoped<IExternalLoginRepository, ExternalLoginRepository>();
46+
services.AddScoped<IOAuthAuthCodeRepository, OAuthAuthCodeRepository>();
4647
services.AddScoped<IApiKeyRepository, ApiKeyRepository>();
4748
services.AddScoped<IKnowledgeSearchService, Taskdeck.Infrastructure.Services.KnowledgeFtsSearchService>();
4849
services.AddScoped<IUnitOfWork, UnitOfWork>();

0 commit comments

Comments
 (0)