From 2a9cf64583cc3930bdbfbe9d088953c77bfc70d7 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:02:41 +0100 Subject: [PATCH 01/19] Add OAuthAuthCode domain entity for DB-backed auth code store Replaces the in-memory ConcurrentDictionary approach with a persistent entity that tracks code, userId, token, expiry, and consumption state. Supports single-use semantics via TryConsume() with TTL enforcement. Part of #676. --- .../Taskdeck.Domain/Entities/OAuthAuthCode.cs | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 backend/src/Taskdeck.Domain/Entities/OAuthAuthCode.cs diff --git a/backend/src/Taskdeck.Domain/Entities/OAuthAuthCode.cs b/backend/src/Taskdeck.Domain/Entities/OAuthAuthCode.cs new file mode 100644 index 000000000..1355fe1e0 --- /dev/null +++ b/backend/src/Taskdeck.Domain/Entities/OAuthAuthCode.cs @@ -0,0 +1,92 @@ +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Domain.Entities; + +/// +/// A short-lived, single-use authorization code issued after OAuth callback. +/// Stored in the database to survive restarts and support multi-instance deployments. +/// +public class OAuthAuthCode : Entity +{ + private string _code = string.Empty; + + public string Code + { + get => _code; + private set + { + if (string.IsNullOrWhiteSpace(value)) + throw new DomainException(ErrorCodes.ValidationError, "Auth code cannot be empty"); + + if (value.Length > 512) + throw new DomainException(ErrorCodes.ValidationError, "Auth code cannot exceed 512 characters"); + + _code = value; + } + } + + /// + /// The user ID this code authenticates. + /// + public Guid UserId { get; private set; } + + /// + /// The pre-serialized JWT token to return on successful exchange. + /// + public string Token { get; private set; } = string.Empty; + + /// + /// When this code expires and can no longer be exchanged. + /// + public DateTimeOffset ExpiresAt { get; private set; } + + /// + /// Whether this code has been consumed (exchanged for a token). + /// + public bool IsConsumed { get; private set; } + + /// + /// When the code was consumed, if applicable. + /// + public DateTimeOffset? ConsumedAt { get; private set; } + + private OAuthAuthCode() : base() { } + + public OAuthAuthCode(string code, Guid userId, string token, DateTimeOffset expiresAt) + : base() + { + if (userId == Guid.Empty) + throw new DomainException(ErrorCodes.ValidationError, "User ID cannot be empty"); + + if (string.IsNullOrWhiteSpace(token)) + throw new DomainException(ErrorCodes.ValidationError, "Token cannot be empty"); + + if (expiresAt <= DateTimeOffset.UtcNow) + throw new DomainException(ErrorCodes.ValidationError, "Expiry must be in the future"); + + Code = code; + UserId = userId; + Token = token; + ExpiresAt = expiresAt; + } + + /// + /// Returns true if this code has expired. + /// + public bool IsExpired => DateTimeOffset.UtcNow > ExpiresAt; + + /// + /// Attempts to consume this code. Returns false if already consumed or expired. + /// + public bool TryConsume() + { + if (IsConsumed || IsExpired) + return false; + + IsConsumed = true; + ConsumedAt = DateTimeOffset.UtcNow; + Touch(); + return true; + } +} From 67c66e77f77e1b5cd9af7bcdb4da90fa55ac3322 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:03:02 +0100 Subject: [PATCH 02/19] Add IOAuthAuthCodeRepository interface and register in IUnitOfWork Defines the application-layer contract for auth code persistence with GetByCodeAsync and DeleteExpiredAsync for TTL cleanup. Part of #676. --- .../Interfaces/IOAuthAuthCodeRepository.cs | 17 +++++++++++++++++ .../Interfaces/IUnitOfWork.cs | 1 + 2 files changed, 18 insertions(+) create mode 100644 backend/src/Taskdeck.Application/Interfaces/IOAuthAuthCodeRepository.cs diff --git a/backend/src/Taskdeck.Application/Interfaces/IOAuthAuthCodeRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IOAuthAuthCodeRepository.cs new file mode 100644 index 000000000..12fb4e245 --- /dev/null +++ b/backend/src/Taskdeck.Application/Interfaces/IOAuthAuthCodeRepository.cs @@ -0,0 +1,17 @@ +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.Interfaces; + +public interface IOAuthAuthCodeRepository : IRepository +{ + /// + /// Finds an auth code by its code string. Returns null if not found. + /// + Task GetByCodeAsync(string code, CancellationToken cancellationToken = default); + + /// + /// Deletes all auth codes that have expired before the specified cutoff time. + /// Returns the number of codes removed. + /// + Task DeleteExpiredAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs b/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs index b6ada47b6..9bd73ff9e 100644 --- a/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs +++ b/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs @@ -27,6 +27,7 @@ public interface IUnitOfWork IKnowledgeDocumentRepository KnowledgeDocuments { get; } IKnowledgeChunkRepository KnowledgeChunks { get; } IExternalLoginRepository ExternalLogins { get; } + IOAuthAuthCodeRepository OAuthAuthCodes { get; } IApiKeyRepository ApiKeys { get; } Task SaveChangesAsync(CancellationToken cancellationToken = default); From 740d9f9eeeb854a3887e583d36f9664683b7e37e Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:04:19 +0100 Subject: [PATCH 03/19] Add OAuthAuthCode infrastructure: EF config, repository, DI, and DbSet Registers OAuthAuthCodes table with unique index on Code and index on ExpiresAt for TTL cleanup. Wires repository through DI and UnitOfWork. Part of #676. --- .../DependencyInjection.cs | 1 + .../OAuthAuthCodeConfiguration.cs | 54 +++++++++++++++++++ .../Persistence/TaskdeckDbContext.cs | 1 + .../Repositories/OAuthAuthCodeRepository.cs | 26 +++++++++ .../Repositories/UnitOfWork.cs | 3 ++ 5 files changed, 85 insertions(+) create mode 100644 backend/src/Taskdeck.Infrastructure/Persistence/Configurations/OAuthAuthCodeConfiguration.cs create mode 100644 backend/src/Taskdeck.Infrastructure/Repositories/OAuthAuthCodeRepository.cs diff --git a/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs b/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs index fee4d4734..8381ba0c6 100644 --- a/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs +++ b/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs @@ -43,6 +43,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/OAuthAuthCodeConfiguration.cs b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/OAuthAuthCodeConfiguration.cs new file mode 100644 index 000000000..9b556e105 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/OAuthAuthCodeConfiguration.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Infrastructure.Persistence.Configurations; + +public class OAuthAuthCodeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("OAuthAuthCodes"); + + builder.HasKey(e => e.Id); + + builder.Property(e => e.Code) + .IsRequired() + .HasMaxLength(512); + + builder.Property(e => e.UserId) + .IsRequired(); + + builder.Property(e => e.Token) + .IsRequired(); + + builder.Property(e => e.ExpiresAt) + .IsRequired(); + + builder.Property(e => e.IsConsumed) + .IsRequired() + .HasDefaultValue(false); + + builder.Property(e => e.ConsumedAt); + + builder.Property(e => e.CreatedAt) + .IsRequired(); + + builder.Property(e => e.UpdatedAt) + .IsRequired(); + + // Unique index on code for fast lookup + builder.HasIndex(e => e.Code) + .IsUnique(); + + // Index for TTL cleanup queries + builder.HasIndex(e => e.ExpiresAt); + + // Foreign key to Users — cascade delete so deleted users + // don't leave orphaned codes + builder.HasOne() + .WithMany() + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs b/backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs index 6509204ab..9e9bac9a0 100644 --- a/backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs +++ b/backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs @@ -39,6 +39,7 @@ public TaskdeckDbContext(DbContextOptions options) : base(opt public DbSet KnowledgeDocuments => Set(); public DbSet KnowledgeChunks => Set(); public DbSet ExternalLogins => Set(); + public DbSet OAuthAuthCodes => Set(); public DbSet ApiKeys => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/OAuthAuthCodeRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/OAuthAuthCodeRepository.cs new file mode 100644 index 000000000..37954ebd3 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Repositories/OAuthAuthCodeRepository.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Entities; +using Taskdeck.Infrastructure.Persistence; + +namespace Taskdeck.Infrastructure.Repositories; + +public class OAuthAuthCodeRepository : Repository, IOAuthAuthCodeRepository +{ + public OAuthAuthCodeRepository(TaskdeckDbContext context) : base(context) + { + } + + public async Task GetByCodeAsync(string code, CancellationToken cancellationToken = default) + { + return await _context.Set() + .FirstOrDefaultAsync(e => e.Code == code, cancellationToken); + } + + public async Task DeleteExpiredAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default) + { + return await _context.Set() + .Where(e => e.ExpiresAt < cutoff) + .ExecuteDeleteAsync(cancellationToken); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs b/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs index 9cd2cf84a..88aeae40b 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs @@ -38,6 +38,7 @@ public UnitOfWork( IKnowledgeDocumentRepository knowledgeDocuments, IKnowledgeChunkRepository knowledgeChunks, IExternalLoginRepository externalLogins, + IOAuthAuthCodeRepository oauthAuthCodes, IApiKeyRepository apiKeys) { _context = context; @@ -66,6 +67,7 @@ public UnitOfWork( KnowledgeDocuments = knowledgeDocuments; KnowledgeChunks = knowledgeChunks; ExternalLogins = externalLogins; + OAuthAuthCodes = oauthAuthCodes; ApiKeys = apiKeys; } @@ -94,6 +96,7 @@ public UnitOfWork( public IKnowledgeDocumentRepository KnowledgeDocuments { get; } public IKnowledgeChunkRepository KnowledgeChunks { get; } public IExternalLoginRepository ExternalLogins { get; } + public IOAuthAuthCodeRepository OAuthAuthCodes { get; } public IApiKeyRepository ApiKeys { get; } public async Task SaveChangesAsync(CancellationToken cancellationToken = default) From ff6e8f02d568f11a2115c6bfee17778626fbfb5e Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:04:54 +0100 Subject: [PATCH 04/19] Add EF migration for OAuthAuthCodes table Creates OAuthAuthCodes table with unique Code index, ExpiresAt index for TTL cleanup, and FK cascade to Users. Part of #676. --- ...260409180437_AddOAuthAuthCodes.Designer.cs | 1923 +++++++++++++++++ .../20260409180437_AddOAuthAuthCodes.cs | 63 + .../TaskdeckDbContextModelSnapshot.cs | 58 +- 3 files changed, 2043 insertions(+), 1 deletion(-) create mode 100644 backend/src/Taskdeck.Infrastructure/Migrations/20260409180437_AddOAuthAuthCodes.Designer.cs create mode 100644 backend/src/Taskdeck.Infrastructure/Migrations/20260409180437_AddOAuthAuthCodes.cs diff --git a/backend/src/Taskdeck.Infrastructure/Migrations/20260409180437_AddOAuthAuthCodes.Designer.cs b/backend/src/Taskdeck.Infrastructure/Migrations/20260409180437_AddOAuthAuthCodes.Designer.cs new file mode 100644 index 000000000..89dae080c --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Migrations/20260409180437_AddOAuthAuthCodes.Designer.cs @@ -0,0 +1,1923 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Taskdeck.Infrastructure.Persistence; + +#nullable disable + +namespace Taskdeck.Infrastructure.Migrations +{ + [DbContext(typeof(TaskdeckDbContext))] + [Migration("20260409180437_AddOAuthAuthCodes")] + partial class AddOAuthAuthCodes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.25"); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AgentProfile", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PolicyJson") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("TEXT"); + + b.Property("ScopeBoardId") + .HasColumnType("TEXT"); + + b.Property("ScopeType") + .HasColumnType("INTEGER"); + + b.Property("TemplateKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TemplateKey"); + + b.HasIndex("UserId"); + + b.ToTable("AgentProfiles", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AgentRun", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentProfileId") + .HasColumnType("TEXT"); + + b.Property("ApproxCostUsd") + .HasColumnType("decimal(10, 6)"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FailureReason") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Objective") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ProposalId") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StepsExecuted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("TokensUsed") + .HasColumnType("INTEGER"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentProfileId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.ToTable("AgentRuns", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AgentRunEvent", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Payload") + .IsRequired() + .HasMaxLength(16000) + .HasColumnType("TEXT"); + + b.Property("RunId") + .HasColumnType("TEXT"); + + b.Property("SequenceNumber") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RunId", "SequenceNumber") + .IsUnique(); + + b.ToTable("AgentRunEvents", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("KeyPrefix_") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT") + .HasColumnName("KeyPrefix"); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("ApiKeys", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ArchiveItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ArchivedAt") + .HasColumnType("TEXT"); + + b.Property("ArchivedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Reason") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RestoreStatus") + .HasColumnType("INTEGER"); + + b.Property("RestoredAt") + .HasColumnType("TEXT"); + + b.Property("RestoredByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SnapshotJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ArchivedAt"); + + b.HasIndex("ArchivedByUserId"); + + b.HasIndex("BoardId"); + + b.HasIndex("RestoreStatus"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("ArchiveItems", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("Changes") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposal", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AppliedAt") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DecidedAt") + .HasColumnType("TEXT"); + + b.Property("DecidedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DiffPreview") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("FailureReason") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RiskLevel") + .HasColumnType("INTEGER"); + + b.Property("SourceReferenceId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValidationIssues") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("Status"); + + b.ToTable("AutomationProposals", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposalOperation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActionType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpectedVersion") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProposalId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Sequence") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IdempotencyKey") + .IsUnique(); + + b.HasIndex("ProposalId"); + + b.HasIndex("ProposalId", "Sequence"); + + b.ToTable("AutomationProposalOperations", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Board", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IsArchived") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("Boards", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.BoardAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("GrantedBy") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("BoardId", "UserId") + .IsUnique(); + + b.ToTable("BoardAccesses", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Card", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BlockReason") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("ColumnId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("DueDate") + .HasColumnType("TEXT"); + + b.Property("IsBlocked") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("ColumnId"); + + b.ToTable("Cards", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CardComment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AuthorUserId") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("CardId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("EditedAt") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("ParentCommentId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorUserId"); + + b.HasIndex("BoardId"); + + b.HasIndex("CardId"); + + b.HasIndex("ParentCommentId"); + + b.HasIndex("CardId", "CreatedAt"); + + b.ToTable("CardComments", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CardCommentMention", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CardCommentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MentionedUserId") + .HasColumnType("TEXT"); + + b.Property("MentionedUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CardCommentId"); + + b.HasIndex("MentionedUserId"); + + b.HasIndex("CardCommentId", "MentionedUserId") + .IsUnique(); + + b.ToTable("CardCommentMentions", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CardLabel", b => + { + b.Property("CardId") + .HasColumnType("TEXT"); + + b.Property("LabelId") + .HasColumnType("TEXT"); + + b.HasKey("CardId", "LabelId"); + + b.HasIndex("LabelId"); + + b.ToTable("CardLabels", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatMessage", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DegradedReason") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("MessageType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProposalId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TokenUsage") + .HasColumnType("INTEGER"); + + b.Property("ToolCallMetadataJson") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("ProposalId"); + + b.HasIndex("SessionId"); + + b.ToTable("ChatMessages", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatSession", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.ToTable("ChatSessions", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Column", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WipLimit") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BoardId", "Position") + .IsUnique(); + + b.ToTable("Columns", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRun", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ExitCode") + .HasColumnType("INTEGER"); + + b.Property("OutputPreview") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TemplateName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Truncated") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("Status"); + + b.ToTable("CommandRuns", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRunLog", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CommandRunId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CommandRunId"); + + b.HasIndex("Level"); + + b.HasIndex("Timestamp"); + + b.ToTable("CommandRunLogs", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ExternalLogin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ProviderUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ProviderUserId") + .IsUnique(); + + b.ToTable("ExternalLogins", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.KnowledgeChunk", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChunkIndex") + .HasColumnType("INTEGER"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DocumentId") + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.HasIndex("DocumentId", "ChunkIndex") + .IsUnique(); + + b.ToTable("KnowledgeChunks", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.KnowledgeDocument", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(50000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsArchived") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsArchived"); + + b.ToTable("KnowledgeDocuments", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Label", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("ColorHex") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.ToTable("Labels", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.LlmRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProcessedAt") + .HasColumnType("TEXT"); + + b.Property("RequestType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("LlmRequests", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.LlmUsageRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InputTokens") + .HasColumnType("INTEGER"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("OutputTokens") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Surface") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.HasIndex("Surface", "CreatedAt"); + + b.HasIndex("UserId", "CreatedAt"); + + b.ToTable("LlmUsageRecords", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Notification", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("Cadence") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeduplicationKey") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsRead") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("SourceEntityId") + .HasColumnType("TEXT"); + + b.Property("SourceEntityType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DeduplicationKey") + .IsUnique(); + + b.HasIndex("UserId", "IsRead"); + + b.ToTable("Notifications", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.NotificationPreference", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AssignmentDigestEnabled") + .HasColumnType("INTEGER"); + + b.Property("AssignmentImmediateEnabled") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InAppChannelEnabled") + .HasColumnType("INTEGER"); + + b.Property("MentionDigestEnabled") + .HasColumnType("INTEGER"); + + b.Property("MentionImmediateEnabled") + .HasColumnType("INTEGER"); + + b.Property("ProposalOutcomeDigestEnabled") + .HasColumnType("INTEGER"); + + b.Property("ProposalOutcomeImmediateEnabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("NotificationPreferences", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.OAuthAuthCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsConsumed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Token") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("UserId"); + + b.ToTable("OAuthAuthCodes", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.OutboundWebhookDelivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttemptCount") + .HasColumnType("INTEGER"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeliveredAt") + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("LastAttemptAt") + .HasColumnType("TEXT"); + + b.Property("LastErrorMessage") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastResponseStatusCode") + .HasColumnType("INTEGER"); + + b.Property("NextAttemptAt") + .HasColumnType("TEXT"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("SubscriptionId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("SubscriptionId"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("OutboundWebhookDeliveries", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.OutboundWebhookSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EventFilters") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("RevokedByUserId") + .HasColumnType("TEXT"); + + b.Property("SigningSecret") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("BoardId", "IsActive"); + + b.ToTable("OutboundWebhookSubscriptions", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DefaultRole") + .HasColumnType("INTEGER"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("TokenInvalidatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.UserPreference", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("OnboardingCompletedAt") + .HasColumnType("TEXT"); + + b.Property("OnboardingDismissedAt") + .HasColumnType("TEXT"); + + b.Property("OnboardingVisibility") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("WorkspaceMode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("UserPreferences", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AgentRun", b => + { + b.HasOne("Taskdeck.Domain.Entities.AgentProfile", null) + .WithMany() + .HasForeignKey("AgentProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AgentRunEvent", b => + { + b.HasOne("Taskdeck.Domain.Entities.AgentRun", "Run") + .WithMany("Events") + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Run"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ApiKey", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AuditLog", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposalOperation", b => + { + b.HasOne("Taskdeck.Domain.Entities.AutomationProposal", "Proposal") + .WithMany("Operations") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Board", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.BoardAccess", b => + { + b.HasOne("Taskdeck.Domain.Entities.Board", "Board") + .WithMany("BoardAccesses") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Taskdeck.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Board"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Card", b => + { + b.HasOne("Taskdeck.Domain.Entities.Board", "Board") + .WithMany("Cards") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Taskdeck.Domain.Entities.Column", "Column") + .WithMany("Cards") + .HasForeignKey("ColumnId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Board"); + + b.Navigation("Column"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CardComment", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", "AuthorUser") + .WithMany() + .HasForeignKey("AuthorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Taskdeck.Domain.Entities.Card", "Card") + .WithMany() + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Taskdeck.Domain.Entities.CardComment", "ParentComment") + .WithMany("Replies") + .HasForeignKey("ParentCommentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AuthorUser"); + + b.Navigation("Card"); + + b.Navigation("ParentComment"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CardCommentMention", b => + { + b.HasOne("Taskdeck.Domain.Entities.CardComment", "CardComment") + .WithMany("Mentions") + .HasForeignKey("CardCommentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Taskdeck.Domain.Entities.User", "MentionedUser") + .WithMany() + .HasForeignKey("MentionedUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CardComment"); + + b.Navigation("MentionedUser"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CardLabel", b => + { + b.HasOne("Taskdeck.Domain.Entities.Card", "Card") + .WithMany("CardLabels") + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Taskdeck.Domain.Entities.Label", "Label") + .WithMany("CardLabels") + .HasForeignKey("LabelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Card"); + + b.Navigation("Label"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatMessage", b => + { + b.HasOne("Taskdeck.Domain.Entities.ChatSession", "Session") + .WithMany("Messages") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Column", b => + { + b.HasOne("Taskdeck.Domain.Entities.Board", "Board") + .WithMany("Columns") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Board"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRunLog", b => + { + b.HasOne("Taskdeck.Domain.Entities.CommandRun", "CommandRun") + .WithMany("Logs") + .HasForeignKey("CommandRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CommandRun"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ExternalLogin", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.KnowledgeChunk", b => + { + b.HasOne("Taskdeck.Domain.Entities.KnowledgeDocument", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Label", b => + { + b.HasOne("Taskdeck.Domain.Entities.Board", "Board") + .WithMany("Labels") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Board"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.LlmRequest", b => + { + b.HasOne("Taskdeck.Domain.Entities.Board", "Board") + .WithMany() + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Taskdeck.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Board"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Notification", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.NotificationPreference", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.OAuthAuthCode", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.OutboundWebhookDelivery", b => + { + b.HasOne("Taskdeck.Domain.Entities.OutboundWebhookSubscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.OutboundWebhookSubscription", b => + { + b.HasOne("Taskdeck.Domain.Entities.Board", "Board") + .WithMany() + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Taskdeck.Domain.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Board"); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.UserPreference", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AgentRun", b => + { + b.Navigation("Events"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposal", b => + { + b.Navigation("Operations"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Board", b => + { + b.Navigation("BoardAccesses"); + + b.Navigation("Cards"); + + b.Navigation("Columns"); + + b.Navigation("Labels"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Card", b => + { + b.Navigation("CardLabels"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CardComment", b => + { + b.Navigation("Mentions"); + + b.Navigation("Replies"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatSession", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Column", b => + { + b.Navigation("Cards"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRun", b => + { + b.Navigation("Logs"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Label", b => + { + b.Navigation("CardLabels"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Migrations/20260409180437_AddOAuthAuthCodes.cs b/backend/src/Taskdeck.Infrastructure/Migrations/20260409180437_AddOAuthAuthCodes.cs new file mode 100644 index 000000000..91589c748 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Migrations/20260409180437_AddOAuthAuthCodes.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Taskdeck.Infrastructure.Migrations +{ + /// + public partial class AddOAuthAuthCodes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "OAuthAuthCodes", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Code = table.Column(type: "TEXT", maxLength: 512, nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + Token = table.Column(type: "TEXT", nullable: false), + ExpiresAt = table.Column(type: "TEXT", nullable: false), + IsConsumed = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + ConsumedAt = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OAuthAuthCodes", x => x.Id); + table.ForeignKey( + name: "FK_OAuthAuthCodes_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_OAuthAuthCodes_Code", + table: "OAuthAuthCodes", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OAuthAuthCodes_ExpiresAt", + table: "OAuthAuthCodes", + column: "ExpiresAt"); + + migrationBuilder.CreateIndex( + name: "IX_OAuthAuthCodes_UserId", + table: "OAuthAuthCodes", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OAuthAuthCodes"); + } + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs b/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs index 4144a5c23..955108bc1 100644 --- a/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs +++ b/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs @@ -15,7 +15,7 @@ partial class TaskdeckDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.14"); + modelBuilder.HasAnnotation("ProductVersion", "8.0.25"); modelBuilder.Entity("Taskdeck.Domain.Entities.AgentProfile", b => { @@ -1300,6 +1300,53 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("NotificationPreferences", (string)null); }); + modelBuilder.Entity("Taskdeck.Domain.Entities.OAuthAuthCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsConsumed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Token") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("UserId"); + + b.ToTable("OAuthAuthCodes", (string)null); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.OutboundWebhookDelivery", b => { b.Property("Id") @@ -1765,6 +1812,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Taskdeck.Domain.Entities.OAuthAuthCode", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.OutboundWebhookDelivery", b => { b.HasOne("Taskdeck.Domain.Entities.OutboundWebhookSubscription", "Subscription") From 29f6eca3836c7b550c6207c1e14a36cccaf39e69 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:15:05 +0100 Subject: [PATCH 05/19] Replace in-memory auth code store with DB-backed OAuthAuthCode, add account linking - OAuthAuthCode entity with Purpose field supporting both login and link flows - DB-backed exchange with TryConsume() for single-use, TTL, replay prevention - AuthController: new IUnitOfWork dependency, async ExchangeCode, CleanupExpiredCodesAsync - Account linking: POST github/link, DELETE github/link, GET linked-accounts endpoints - AuthenticationService: CompleteAccountLinkAsync, UnlinkExternalLoginAsync - LinkedAccountDto for account linking API responses - Updated all test stubs for IUnitOfWork.OAuthAuthCodes - Rewrote OAuthTokenLifecycleTests and AuthControllerEdgeCaseTests for DB-backed store Part of #676. --- .../Controllers/AuthController.cs | 218 +++++++++++++++--- .../src/Taskdeck.Application/DTOs/UserDtos.cs | 7 + .../Services/AuthenticationService.cs | 110 +++++++++ .../Services/IAuthenticationService.cs | 3 + .../Taskdeck.Domain/Entities/OAuthAuthCode.cs | 48 +++- ...60409181436_AddOAuthAuthCodes.Designer.cs} | 24 +- ...cs => 20260409181436_AddOAuthAuthCodes.cs} | 13 +- .../TaskdeckDbContextModelSnapshot.cs | 22 +- .../OAuthAuthCodeConfiguration.cs | 15 +- .../ActiveUserValidationMiddlewareTests.cs | 1 + .../AuthControllerEdgeCaseTests.cs | 126 +++++----- .../LlmQueueToProposalWorkerTests.cs | 1 + .../OAuthTokenLifecycleTests.cs | 175 +++++++------- ...ndWebhookDeliveryWorkerReliabilityTests.cs | 1 + .../OutboundWebhookDeliveryWorkerTests.cs | 1 + .../OutboundWebhookHmacDeliveryTests.cs | 1 + ...ProposalHousekeepingWorkerEdgeCaseTests.cs | 1 + .../ProposalHousekeepingWorkerTests.cs | 1 + .../WorkerResilienceTests.cs | 2 + 19 files changed, 558 insertions(+), 212 deletions(-) rename backend/src/Taskdeck.Infrastructure/Migrations/{20260409180437_AddOAuthAuthCodes.Designer.cs => 20260409181436_AddOAuthAuthCodes.Designer.cs} (99%) rename backend/src/Taskdeck.Infrastructure/Migrations/{20260409180437_AddOAuthAuthCodes.cs => 20260409181436_AddOAuthAuthCodes.cs} (80%) diff --git a/backend/src/Taskdeck.Api/Controllers/AuthController.cs b/backend/src/Taskdeck.Api/Controllers/AuthController.cs index 06c544690..7832c2daa 100644 --- a/backend/src/Taskdeck.Api/Controllers/AuthController.cs +++ b/backend/src/Taskdeck.Api/Controllers/AuthController.cs @@ -1,5 +1,5 @@ -using System.Collections.Concurrent; using System.Security.Cryptography; +using System.Text.Json; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -11,6 +11,7 @@ using Taskdeck.Application.DTOs; using Taskdeck.Application.Interfaces; using Taskdeck.Application.Services; +using Taskdeck.Domain.Entities; using Taskdeck.Domain.Exceptions; using AuthenticationService = Taskdeck.Application.Services.AuthenticationService; @@ -18,6 +19,7 @@ namespace Taskdeck.Api.Controllers; public record ChangePasswordRequest(string CurrentPassword, string NewPassword); public record ExchangeCodeRequest(string Code); +public record LinkExchangeRequest(string Code); /// /// Authentication endpoints — register, login, change password, and GitHub OAuth flow. @@ -30,16 +32,14 @@ public class AuthController : AuthenticatedControllerBase { private readonly AuthenticationService _authService; private readonly GitHubOAuthSettings _gitHubOAuthSettings; + private readonly IUnitOfWork _unitOfWork; - // Short-lived, single-use authorization codes to avoid exposing JWT in URLs. - // Key: code, Value: (token, expiry). Codes expire after 60 seconds. - private static readonly ConcurrentDictionary _authCodes = new(); - - public AuthController(AuthenticationService authService, GitHubOAuthSettings gitHubOAuthSettings, IUserContext userContext) + public AuthController(AuthenticationService authService, GitHubOAuthSettings gitHubOAuthSettings, IUserContext userContext, IUnitOfWork unitOfWork) : base(userContext) { _authService = authService; _gitHubOAuthSettings = gitHubOAuthSettings; + _unitOfWork = unitOfWork; } /// @@ -117,10 +117,11 @@ public async Task ChangePassword([FromBody] ChangePasswordRequest /// /// Initiates GitHub OAuth login flow. Only available when GitHub OAuth is configured. + /// Pass mode=link to start an account-linking flow instead of login. /// [HttpGet("github/login")] [EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)] - public IActionResult GitHubLogin([FromQuery] string? returnUrl = null) + public IActionResult GitHubLogin([FromQuery] string? returnUrl = null, [FromQuery] string? mode = null) { if (!_gitHubOAuthSettings.IsConfigured) return NotFound(new ApiErrorResponse(ErrorCodes.NotFound, "GitHub OAuth is not configured")); @@ -131,10 +132,16 @@ public IActionResult GitHubLogin([FromQuery] string? returnUrl = null) var properties = new Microsoft.AspNetCore.Authentication.AuthenticationProperties { - RedirectUri = Url.Action(nameof(GitHubCallback), new { returnUrl }), + RedirectUri = Url.Action(nameof(GitHubCallback), new { returnUrl, mode }), Items = { { "LoginProvider", "GitHub" } } }; + // Store mode in the auth properties so the callback can detect linking + if (mode == "link") + { + properties.Items["mode"] = "link"; + } + return Challenge(properties, "GitHub"); } @@ -143,7 +150,7 @@ public IActionResult GitHubLogin([FromQuery] string? returnUrl = null) /// [HttpGet("github/callback")] [EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)] - public async Task GitHubCallback([FromQuery] string? returnUrl = null) + public async Task GitHubCallback([FromQuery] string? returnUrl = null, [FromQuery] string? mode = null) { if (!_gitHubOAuthSettings.IsConfigured) return NotFound(new ApiErrorResponse(ErrorCodes.NotFound, "GitHub OAuth is not configured")); @@ -171,6 +178,38 @@ public async Task GitHubCallback([FromQuery] string? returnUrl = "GitHub did not return a user identifier")); } + // Sign out the temporary cookie used during the OAuth handshake + await HttpContext.SignOutAsync("GitHub"); + + // Account linking flow: store the GitHub identity as a link code + if (mode == "link") + { + var linkCode = GenerateAuthCode(); + var providerData = JsonSerializer.Serialize(new + { + provider = "GitHub", + providerUserId, + displayName, + avatarUrl + }); + + var linkAuthCode = OAuthAuthCode.CreateForLinking( + code: linkCode, + providerData: providerData, + expiresAt: DateTimeOffset.UtcNow.AddSeconds(60)); + + await _unitOfWork.OAuthAuthCodes.AddAsync(linkAuthCode); + await _unitOfWork.SaveChangesAsync(); + + var linkReturnUrl = !string.IsNullOrWhiteSpace(returnUrl) && Url.IsLocalUrl(returnUrl) + ? returnUrl + : "/"; + + var linkSeparator = linkReturnUrl.Contains('?') ? "&" : "?"; + return Redirect($"{linkReturnUrl}{linkSeparator}oauth_link_code={Uri.EscapeDataString(linkCode)}"); + } + + // Normal login flow // GitHub may not return an email if user's email is private if (string.IsNullOrWhiteSpace(email)) email = $"{providerUserId}@users.noreply.github.com"; @@ -191,14 +230,20 @@ public async Task GitHubCallback([FromQuery] string? returnUrl = if (!result.IsSuccess) return result.ToErrorActionResult(); - // Sign out the temporary cookie used during the OAuth handshake - await HttpContext.SignOutAsync("GitHub"); - - // Security: Do NOT put the JWT in the URL. Use a short-lived, single-use - // authorization code that the frontend exchanges via POST. + // Store the authorization code in the database instead of in-memory. + // This survives restarts and works with multi-instance deployments. var code = GenerateAuthCode(); - _authCodes[code] = (result.Value, DateTimeOffset.UtcNow.AddSeconds(60)); - CleanupExpiredCodes(); + var authCode = new OAuthAuthCode( + code: code, + userId: result.Value.User.Id, + token: result.Value.Token, + expiresAt: DateTimeOffset.UtcNow.AddSeconds(60)); + + await _unitOfWork.OAuthAuthCodes.AddAsync(authCode); + await _unitOfWork.SaveChangesAsync(); + + // Best-effort cleanup of expired codes + _ = CleanupExpiredCodesAsync(); var safeReturnUrl = !string.IsNullOrWhiteSpace(returnUrl) && Url.IsLocalUrl(returnUrl) ? returnUrl @@ -214,18 +259,137 @@ public async Task GitHubCallback([FromQuery] string? returnUrl = /// [HttpPost("github/exchange")] [EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)] - public IActionResult ExchangeCode([FromBody] ExchangeCodeRequest request) + public async Task ExchangeCode([FromBody] ExchangeCodeRequest request) { if (string.IsNullOrWhiteSpace(request.Code)) return BadRequest(new ApiErrorResponse(ErrorCodes.ValidationError, "Code is required")); - if (!_authCodes.TryRemove(request.Code, out var entry)) + var authCode = await _unitOfWork.OAuthAuthCodes.GetByCodeAsync(request.Code); + if (authCode == null) return Unauthorized(new ApiErrorResponse(ErrorCodes.AuthenticationFailed, "Invalid or expired code")); - if (DateTimeOffset.UtcNow > entry.Expiry) - return Unauthorized(new ApiErrorResponse(ErrorCodes.AuthenticationFailed, "Code has expired")); + if (authCode.IsLinkingCode) + return BadRequest(new ApiErrorResponse(ErrorCodes.ValidationError, "This code is for account linking, not login")); + + if (!authCode.TryConsume()) + { + var message = authCode.IsExpired ? "Code has expired" : "Invalid or expired code"; + return Unauthorized(new ApiErrorResponse(ErrorCodes.AuthenticationFailed, message)); + } + + await _unitOfWork.SaveChangesAsync(); - return Ok(entry.Result); + // Look up the user to build the AuthResultDto + var user = await _unitOfWork.Users.GetByIdAsync(authCode.UserId); + if (user == null) + return Unauthorized(new ApiErrorResponse(ErrorCodes.AuthenticationFailed, "User not found")); + + var userDto = new UserDto( + user.Id, + user.Username, + user.Email, + user.DefaultRole, + user.IsActive, + user.CreatedAt, + user.UpdatedAt); + + return Ok(new AuthResultDto(authCode.Token, userDto)); + } + + /// + /// Exchanges a link code and associates the GitHub account with the authenticated user. + /// Requires a valid JWT session. + /// + [HttpPost("github/link")] + [Authorize] + [EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)] + [ProducesResponseType(typeof(LinkedAccountDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)] + public async Task LinkGitHub([FromBody] LinkExchangeRequest request) + { + if (!_gitHubOAuthSettings.IsConfigured) + return NotFound(new ApiErrorResponse(ErrorCodes.NotFound, "GitHub OAuth is not configured")); + + if (!TryGetCurrentUserId(out var callerUserId, out var errorResult)) + return errorResult!; + + if (string.IsNullOrWhiteSpace(request.Code)) + return BadRequest(new ApiErrorResponse(ErrorCodes.ValidationError, "Link code is required")); + + // Look up and consume the link code + var authCode = await _unitOfWork.OAuthAuthCodes.GetByCodeAsync(request.Code); + if (authCode == null) + return Unauthorized(new ApiErrorResponse(ErrorCodes.AuthenticationFailed, "Invalid or expired link code")); + + if (!authCode.IsLinkingCode) + return BadRequest(new ApiErrorResponse(ErrorCodes.ValidationError, "This code is for login, not account linking")); + + if (!authCode.TryConsume()) + { + var message = authCode.IsExpired ? "Link code has expired" : "Invalid or expired link code"; + return Unauthorized(new ApiErrorResponse(ErrorCodes.AuthenticationFailed, message)); + } + + await _unitOfWork.SaveChangesAsync(); + + // Parse the provider data from the link code + if (string.IsNullOrWhiteSpace(authCode.ProviderData)) + return BadRequest(new ApiErrorResponse(ErrorCodes.ValidationError, "Link code contains no provider data")); + + var providerInfo = JsonSerializer.Deserialize(authCode.ProviderData); + var provider = providerInfo.GetProperty("provider").GetString() ?? "GitHub"; + var providerUserId = providerInfo.GetProperty("providerUserId").GetString(); + var displayName = providerInfo.TryGetProperty("displayName", out var dn) ? dn.GetString() : null; + var avatarUrl = providerInfo.TryGetProperty("avatarUrl", out var av) ? av.GetString() : null; + + if (string.IsNullOrWhiteSpace(providerUserId)) + return BadRequest(new ApiErrorResponse(ErrorCodes.ValidationError, "Provider user ID is missing from link code")); + + var result = await _authService.CompleteAccountLinkAsync(callerUserId, provider, providerUserId, displayName, avatarUrl); + return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); + } + + /// + /// Unlinks a GitHub account from the authenticated user. + /// + [HttpDelete("github/link")] + [Authorize] + [EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + public async Task UnlinkGitHub() + { + if (!TryGetCurrentUserId(out var callerUserId, out var errorResult)) + return errorResult!; + + var result = await _authService.UnlinkExternalLoginAsync(callerUserId, "GitHub"); + return result.IsSuccess ? NoContent() : result.ToErrorActionResult(); + } + + /// + /// Returns the external logins linked to the authenticated user. + /// + [HttpGet("linked-accounts")] + [Authorize] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + public async Task GetLinkedAccounts() + { + if (!TryGetCurrentUserId(out var callerUserId, out var errorResult)) + return errorResult!; + + var logins = await _unitOfWork.ExternalLogins.GetByUserIdAsync(callerUserId); + var dtos = logins.Select(l => new LinkedAccountDto( + l.Provider, + l.ProviderUserId, + l.ProviderDisplayName, + l.AvatarUrl, + l.CreatedAt)); + + return Ok(dtos); } /// @@ -246,13 +410,15 @@ private static string GenerateAuthCode() return Convert.ToBase64String(bytes).Replace("+", "-").Replace("/", "_").TrimEnd('='); } - private static void CleanupExpiredCodes() + private async Task CleanupExpiredCodesAsync() { - var now = DateTimeOffset.UtcNow; - foreach (var kvp in _authCodes) + try + { + await _unitOfWork.OAuthAuthCodes.DeleteExpiredAsync(DateTimeOffset.UtcNow); + } + catch { - if (now > kvp.Value.Expiry) - _authCodes.TryRemove(kvp.Key, out _); + // Cleanup failure is non-critical } } } diff --git a/backend/src/Taskdeck.Application/DTOs/UserDtos.cs b/backend/src/Taskdeck.Application/DTOs/UserDtos.cs index 83e0f60a7..18d12027e 100644 --- a/backend/src/Taskdeck.Application/DTOs/UserDtos.cs +++ b/backend/src/Taskdeck.Application/DTOs/UserDtos.cs @@ -37,3 +37,10 @@ public record ExternalLoginDto( string Email, string? DisplayName = null, string? AvatarUrl = null); + +public record LinkedAccountDto( + string Provider, + string ProviderUserId, + string? DisplayName, + string? AvatarUrl, + DateTimeOffset LinkedAt); diff --git a/backend/src/Taskdeck.Application/Services/AuthenticationService.cs b/backend/src/Taskdeck.Application/Services/AuthenticationService.cs index 179f3b0ec..0d1901e66 100644 --- a/backend/src/Taskdeck.Application/Services/AuthenticationService.cs +++ b/backend/src/Taskdeck.Application/Services/AuthenticationService.cs @@ -209,6 +209,116 @@ public async Task> ExternalLoginAsync(ExternalLoginDto dto } } + public async Task> LinkExternalLoginAsync(Guid userId, string provider) + { + try + { + if (userId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "User ID is required"); + + if (string.IsNullOrWhiteSpace(provider)) + return Result.Failure(ErrorCodes.ValidationError, "Provider is required"); + + var user = await _unitOfWork.Users.GetByIdAsync(userId); + if (user == null) + return Result.Failure(ErrorCodes.NotFound, "User not found"); + + if (!user.IsActive) + return Result.Failure(ErrorCodes.Forbidden, "User account is inactive"); + + // Check if user already has a linked account for this provider + var existingLogins = await _unitOfWork.ExternalLogins.GetByUserIdAsync(userId); + if (existingLogins.Any(l => l.Provider == provider)) + return Result.Failure(ErrorCodes.Conflict, $"Account is already linked to {provider}"); + + // Return a placeholder indicating the link flow should proceed via OAuth + // The actual linking happens in CompleteAccountLinkAsync after OAuth callback + return Result.Failure(ErrorCodes.ValidationError, + "Account linking requires completing the OAuth flow. Use the GitHub login redirect with mode=link."); + } + catch (DomainException ex) + { + return Result.Failure(ex.ErrorCode, ex.Message); + } + } + + public async Task> CompleteAccountLinkAsync(Guid userId, string provider, string providerUserId, string? displayName, string? avatarUrl) + { + try + { + if (userId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "User ID is required"); + + var user = await _unitOfWork.Users.GetByIdAsync(userId); + if (user == null) + return Result.Failure(ErrorCodes.NotFound, "User not found"); + + if (!user.IsActive) + return Result.Failure(ErrorCodes.Forbidden, "User account is inactive"); + + // Check if this provider+userId combo is already linked to a different user + var existingLogin = await _unitOfWork.ExternalLogins.GetByProviderAsync(provider, providerUserId); + if (existingLogin != null) + { + if (existingLogin.UserId == userId) + return Result.Failure(ErrorCodes.Conflict, $"This {provider} account is already linked to your account"); + + return Result.Failure(ErrorCodes.Conflict, $"This {provider} account is already linked to a different user"); + } + + // Check if user already has a linked account for this provider + var userLogins = await _unitOfWork.ExternalLogins.GetByUserIdAsync(userId); + if (userLogins.Any(l => l.Provider == provider)) + return Result.Failure(ErrorCodes.Conflict, $"Your account is already linked to a {provider} account"); + + var newLogin = new ExternalLogin(userId, provider, providerUserId, displayName, avatarUrl); + await _unitOfWork.ExternalLogins.AddAsync(newLogin); + await _unitOfWork.SaveChangesAsync(); + + return Result.Success(new LinkedAccountDto( + newLogin.Provider, + newLogin.ProviderUserId, + newLogin.ProviderDisplayName, + newLogin.AvatarUrl, + newLogin.CreatedAt)); + } + catch (DomainException ex) + { + return Result.Failure(ex.ErrorCode, ex.Message); + } + catch (Exception) + { + return Result.Failure(ErrorCodes.UnexpectedError, "Account linking failed due to an unexpected error"); + } + } + + public async Task UnlinkExternalLoginAsync(Guid userId, string provider) + { + try + { + if (userId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "User ID is required"); + + var user = await _unitOfWork.Users.GetByIdAsync(userId); + if (user == null) + return Result.Failure(ErrorCodes.NotFound, "User not found"); + + var logins = await _unitOfWork.ExternalLogins.GetByUserIdAsync(userId); + var loginToRemove = logins.FirstOrDefault(l => l.Provider == provider); + if (loginToRemove == null) + return Result.Failure(ErrorCodes.NotFound, $"No {provider} account is linked"); + + await _unitOfWork.ExternalLogins.DeleteAsync(loginToRemove); + await _unitOfWork.SaveChangesAsync(); + + return Result.Success(); + } + catch (DomainException ex) + { + return Result.Failure(ex.ErrorCode, ex.Message); + } + } + public async Task ChangePasswordAsync(Guid userId, string currentPassword, string newPassword) { try diff --git a/backend/src/Taskdeck.Application/Services/IAuthenticationService.cs b/backend/src/Taskdeck.Application/Services/IAuthenticationService.cs index 7b62e6fcd..3a26f7147 100644 --- a/backend/src/Taskdeck.Application/Services/IAuthenticationService.cs +++ b/backend/src/Taskdeck.Application/Services/IAuthenticationService.cs @@ -14,4 +14,7 @@ public interface IAuthenticationService Task ChangePasswordAsync(Guid userId, string currentPassword, string newPassword); Task> ValidateTokenAsync(string token); Task> ExternalLoginAsync(ExternalLoginDto dto); + Task> LinkExternalLoginAsync(Guid userId, string provider); + Task> CompleteAccountLinkAsync(Guid userId, string provider, string providerUserId, string? displayName, string? avatarUrl); + Task UnlinkExternalLoginAsync(Guid userId, string provider); } diff --git a/backend/src/Taskdeck.Domain/Entities/OAuthAuthCode.cs b/backend/src/Taskdeck.Domain/Entities/OAuthAuthCode.cs index 1355fe1e0..831790410 100644 --- a/backend/src/Taskdeck.Domain/Entities/OAuthAuthCode.cs +++ b/backend/src/Taskdeck.Domain/Entities/OAuthAuthCode.cs @@ -6,6 +6,7 @@ namespace Taskdeck.Domain.Entities; /// /// A short-lived, single-use authorization code issued after OAuth callback. /// Stored in the database to survive restarts and support multi-instance deployments. +/// Supports both login (Token populated) and account-linking (ProviderData populated) flows. /// public class OAuthAuthCode : Entity { @@ -27,15 +28,26 @@ private set } /// - /// The user ID this code authenticates. + /// The user ID this code authenticates (login flow) or Guid.Empty (link flow). /// public Guid UserId { get; private set; } /// - /// The pre-serialized JWT token to return on successful exchange. + /// The pre-serialized JWT token to return on successful exchange (login flow only). /// public string Token { get; private set; } = string.Empty; + /// + /// The purpose of this code: "login" or "link". + /// + public string Purpose { get; private set; } = "login"; + + /// + /// JSON-serialized provider identity data for account linking flows. + /// Contains provider, providerUserId, displayName, avatarUrl. + /// + public string? ProviderData { get; private set; } + /// /// When this code expires and can no longer be exchanged. /// @@ -53,6 +65,9 @@ private set private OAuthAuthCode() : base() { } + /// + /// Creates an auth code for the login flow (token exchange). + /// public OAuthAuthCode(string code, Guid userId, string token, DateTimeOffset expiresAt) : base() { @@ -68,14 +83,43 @@ public OAuthAuthCode(string code, Guid userId, string token, DateTimeOffset expi Code = code; UserId = userId; Token = token; + Purpose = "login"; ExpiresAt = expiresAt; } + /// + /// Creates an auth code for the account linking flow (provider identity exchange). + /// + public static OAuthAuthCode CreateForLinking(string code, string providerData, DateTimeOffset expiresAt) + { + if (string.IsNullOrWhiteSpace(providerData)) + throw new DomainException(ErrorCodes.ValidationError, "Provider data cannot be empty for linking"); + + if (expiresAt <= DateTimeOffset.UtcNow) + throw new DomainException(ErrorCodes.ValidationError, "Expiry must be in the future"); + + var entity = new OAuthAuthCode + { + Code = code, + UserId = Guid.Empty, + Token = string.Empty, + Purpose = "link", + ProviderData = providerData, + ExpiresAt = expiresAt + }; + return entity; + } + /// /// Returns true if this code has expired. /// public bool IsExpired => DateTimeOffset.UtcNow > ExpiresAt; + /// + /// Returns true if this is a linking code (not a login code). + /// + public bool IsLinkingCode => Purpose == "link"; + /// /// Attempts to consume this code. Returns false if already consumed or expired. /// diff --git a/backend/src/Taskdeck.Infrastructure/Migrations/20260409180437_AddOAuthAuthCodes.Designer.cs b/backend/src/Taskdeck.Infrastructure/Migrations/20260409181436_AddOAuthAuthCodes.Designer.cs similarity index 99% rename from backend/src/Taskdeck.Infrastructure/Migrations/20260409180437_AddOAuthAuthCodes.Designer.cs rename to backend/src/Taskdeck.Infrastructure/Migrations/20260409181436_AddOAuthAuthCodes.Designer.cs index 89dae080c..d26a1e3a0 100644 --- a/backend/src/Taskdeck.Infrastructure/Migrations/20260409180437_AddOAuthAuthCodes.Designer.cs +++ b/backend/src/Taskdeck.Infrastructure/Migrations/20260409181436_AddOAuthAuthCodes.Designer.cs @@ -11,7 +11,7 @@ namespace Taskdeck.Infrastructure.Migrations { [DbContext(typeof(TaskdeckDbContext))] - [Migration("20260409180437_AddOAuthAuthCodes")] + [Migration("20260409181436_AddOAuthAuthCodes")] partial class AddOAuthAuthCodes { /// @@ -1328,6 +1328,17 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("INTEGER") .HasDefaultValue(false); + b.Property("ProviderData") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Purpose") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasDefaultValue("login"); + b.Property("Token") .IsRequired() .HasColumnType("TEXT"); @@ -1345,8 +1356,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("ExpiresAt"); - b.HasIndex("UserId"); - b.ToTable("OAuthAuthCodes", (string)null); }); @@ -1815,15 +1824,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("Taskdeck.Domain.Entities.OAuthAuthCode", b => - { - b.HasOne("Taskdeck.Domain.Entities.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - modelBuilder.Entity("Taskdeck.Domain.Entities.OutboundWebhookDelivery", b => { b.HasOne("Taskdeck.Domain.Entities.OutboundWebhookSubscription", "Subscription") diff --git a/backend/src/Taskdeck.Infrastructure/Migrations/20260409180437_AddOAuthAuthCodes.cs b/backend/src/Taskdeck.Infrastructure/Migrations/20260409181436_AddOAuthAuthCodes.cs similarity index 80% rename from backend/src/Taskdeck.Infrastructure/Migrations/20260409180437_AddOAuthAuthCodes.cs rename to backend/src/Taskdeck.Infrastructure/Migrations/20260409181436_AddOAuthAuthCodes.cs index 91589c748..38b498467 100644 --- a/backend/src/Taskdeck.Infrastructure/Migrations/20260409180437_AddOAuthAuthCodes.cs +++ b/backend/src/Taskdeck.Infrastructure/Migrations/20260409181436_AddOAuthAuthCodes.cs @@ -19,6 +19,8 @@ protected override void Up(MigrationBuilder migrationBuilder) Code = table.Column(type: "TEXT", maxLength: 512, nullable: false), UserId = table.Column(type: "TEXT", nullable: false), Token = table.Column(type: "TEXT", nullable: false), + Purpose = table.Column(type: "TEXT", maxLength: 20, nullable: false, defaultValue: "login"), + ProviderData = table.Column(type: "TEXT", maxLength: 4096, nullable: true), ExpiresAt = table.Column(type: "TEXT", nullable: false), IsConsumed = table.Column(type: "INTEGER", nullable: false, defaultValue: false), ConsumedAt = table.Column(type: "TEXT", nullable: true), @@ -28,12 +30,6 @@ protected override void Up(MigrationBuilder migrationBuilder) constraints: table => { table.PrimaryKey("PK_OAuthAuthCodes", x => x.Id); - table.ForeignKey( - name: "FK_OAuthAuthCodes_Users_UserId", - column: x => x.UserId, - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); }); migrationBuilder.CreateIndex( @@ -46,11 +42,6 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "IX_OAuthAuthCodes_ExpiresAt", table: "OAuthAuthCodes", column: "ExpiresAt"); - - migrationBuilder.CreateIndex( - name: "IX_OAuthAuthCodes_UserId", - table: "OAuthAuthCodes", - column: "UserId"); } /// diff --git a/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs b/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs index 955108bc1..fde5581ed 100644 --- a/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs +++ b/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs @@ -1325,6 +1325,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("INTEGER") .HasDefaultValue(false); + b.Property("ProviderData") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Purpose") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasDefaultValue("login"); + b.Property("Token") .IsRequired() .HasColumnType("TEXT"); @@ -1342,8 +1353,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ExpiresAt"); - b.HasIndex("UserId"); - b.ToTable("OAuthAuthCodes", (string)null); }); @@ -1812,15 +1821,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("Taskdeck.Domain.Entities.OAuthAuthCode", b => - { - b.HasOne("Taskdeck.Domain.Entities.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - modelBuilder.Entity("Taskdeck.Domain.Entities.OutboundWebhookDelivery", b => { b.HasOne("Taskdeck.Domain.Entities.OutboundWebhookSubscription", "Subscription") diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/OAuthAuthCodeConfiguration.cs b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/OAuthAuthCodeConfiguration.cs index 9b556e105..4c02e19fe 100644 --- a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/OAuthAuthCodeConfiguration.cs +++ b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/OAuthAuthCodeConfiguration.cs @@ -22,6 +22,14 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.Token) .IsRequired(); + builder.Property(e => e.Purpose) + .IsRequired() + .HasMaxLength(20) + .HasDefaultValue("login"); + + builder.Property(e => e.ProviderData) + .HasMaxLength(4096); + builder.Property(e => e.ExpiresAt) .IsRequired(); @@ -43,12 +51,5 @@ public void Configure(EntityTypeBuilder builder) // Index for TTL cleanup queries builder.HasIndex(e => e.ExpiresAt); - - // Foreign key to Users — cascade delete so deleted users - // don't leave orphaned codes - builder.HasOne() - .WithMany() - .HasForeignKey(e => e.UserId) - .OnDelete(DeleteBehavior.Cascade); } } diff --git a/backend/tests/Taskdeck.Api.Tests/ActiveUserValidationMiddlewareTests.cs b/backend/tests/Taskdeck.Api.Tests/ActiveUserValidationMiddlewareTests.cs index 6b5b2882f..1bfeafc2e 100644 --- a/backend/tests/Taskdeck.Api.Tests/ActiveUserValidationMiddlewareTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/ActiveUserValidationMiddlewareTests.cs @@ -300,6 +300,7 @@ public StubUnitOfWork(User? userToReturn) public IKnowledgeDocumentRepository KnowledgeDocuments => throw new NotImplementedException(); public IKnowledgeChunkRepository KnowledgeChunks => throw new NotImplementedException(); public IExternalLoginRepository ExternalLogins => throw new NotImplementedException(); + public IOAuthAuthCodeRepository OAuthAuthCodes => throw new NotImplementedException(); public IApiKeyRepository ApiKeys => throw new NotImplementedException(); public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(0); diff --git a/backend/tests/Taskdeck.Api.Tests/AuthControllerEdgeCaseTests.cs b/backend/tests/Taskdeck.Api.Tests/AuthControllerEdgeCaseTests.cs index 05a29d18f..7e5f679ed 100644 --- a/backend/tests/Taskdeck.Api.Tests/AuthControllerEdgeCaseTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/AuthControllerEdgeCaseTests.cs @@ -1,6 +1,4 @@ -using System.Collections.Concurrent; using System.IdentityModel.Tokens.Jwt; -using System.Reflection; using System.Security.Claims; using System.Text.Json; using FluentAssertions; @@ -41,10 +39,10 @@ public class AuthControllerEdgeCaseTests // ───────────────────────────────────────────────────────── [Fact] - public void ExchangeCode_ShouldReturn400_WhenCodeIsEmpty() + public async Task ExchangeCode_ShouldReturn400_WhenCodeIsEmpty() { - var controller = CreateAuthController(); - var result = controller.ExchangeCode(new ExchangeCodeRequest(string.Empty)); + var (controller, _) = CreateAuthControllerWithUnitOfWork(); + var result = await controller.ExchangeCode(new ExchangeCodeRequest(string.Empty)); var badRequest = result.Should().BeOfType().Subject; var error = badRequest.Value.Should().BeOfType().Subject; @@ -52,10 +50,10 @@ public void ExchangeCode_ShouldReturn400_WhenCodeIsEmpty() } [Fact] - public void ExchangeCode_ShouldReturn401_WhenCodeIsInvalid() + public async Task ExchangeCode_ShouldReturn401_WhenCodeIsInvalid() { - var controller = CreateAuthController(); - var result = controller.ExchangeCode(new ExchangeCodeRequest("nonexistent-code")); + var (controller, _) = CreateAuthControllerWithUnitOfWork(); + var result = await controller.ExchangeCode(new ExchangeCodeRequest("nonexistent-code")); var unauthorized = result.Should().BeOfType().Subject; var error = unauthorized.Value.Should().BeOfType().Subject; @@ -63,44 +61,58 @@ public void ExchangeCode_ShouldReturn401_WhenCodeIsInvalid() } [Fact] - public void ExchangeCode_ShouldPreventReplay_SecondUseOfSameCode() + public async Task ExchangeCode_ShouldPreventReplay_SecondUseOfSameCode() { - // Insert a code into the static dictionary via reflection + var (controller, uow) = CreateAuthControllerWithUnitOfWork(); + + // Create a valid auth code var code = "test-replay-code"; - var authResult = new AuthResultDto("fake-token", new UserDto( - Guid.NewGuid(), "user", "user@test.com", - Domain.Enums.UserRole.Editor, true, - DateTimeOffset.UtcNow, DateTimeOffset.UtcNow)); + var authCode = new OAuthAuthCode(code, Guid.NewGuid(), "fake-token", DateTimeOffset.UtcNow.AddSeconds(60)); + + var authCodeRepoMock = new Mock(); + // First call returns the code, second call returns null (code was consumed) + var callCount = 0; + authCodeRepoMock.Setup(r => r.GetByCodeAsync(code, It.IsAny())) + .ReturnsAsync(() => + { + callCount++; + return callCount == 1 ? authCode : null; + }); - InjectAuthCode(code, authResult, DateTimeOffset.UtcNow.AddSeconds(60)); + var userRepoMock = new Mock(); + var testUser = new User("testuser", "test@test.com", BCrypt.Net.BCrypt.HashPassword("pass")); + SetUserId(testUser, authCode.UserId); + userRepoMock.Setup(r => r.GetByIdAsync(authCode.UserId, It.IsAny())) + .ReturnsAsync(testUser); - var controller = CreateAuthController(); + uow.Setup(u => u.OAuthAuthCodes).Returns(authCodeRepoMock.Object); + uow.Setup(u => u.Users).Returns(userRepoMock.Object); // First exchange — success - var first = controller.ExchangeCode(new ExchangeCodeRequest(code)); + var first = await controller.ExchangeCode(new ExchangeCodeRequest(code)); first.Should().BeOfType(); - // Second exchange with same code — should fail (code was consumed) - var second = controller.ExchangeCode(new ExchangeCodeRequest(code)); + // Second exchange with same code — should fail (code not found by mock) + var second = await controller.ExchangeCode(new ExchangeCodeRequest(code)); second.Should().BeOfType(); } [Fact] - public void ExchangeCode_ShouldReturn401_WhenCodeHasExpired() + public async Task ExchangeCode_ShouldReturn401_WhenCodeHasExpired() { + var (controller, uow) = CreateAuthControllerWithUnitOfWork(); + + // Create a code that is already expired by manipulating the entity via reflection var code = "test-expired-code"; - var authResult = new AuthResultDto("fake-token", new UserDto( - Guid.NewGuid(), "user", "user@test.com", - Domain.Enums.UserRole.Editor, true, - DateTimeOffset.UtcNow, DateTimeOffset.UtcNow)); + var authCode = CreateExpiredAuthCode(code); - // Inject code that expired 10 seconds ago - InjectAuthCode(code, authResult, DateTimeOffset.UtcNow.AddSeconds(-10)); + var authCodeRepoMock = new Mock(); + authCodeRepoMock.Setup(r => r.GetByCodeAsync(code, It.IsAny())) + .ReturnsAsync(authCode); + uow.Setup(u => u.OAuthAuthCodes).Returns(authCodeRepoMock.Object); - var controller = CreateAuthController(); - var result = controller.ExchangeCode(new ExchangeCodeRequest(code)); + var result = await controller.ExchangeCode(new ExchangeCodeRequest(code)); - // TryRemove succeeds (code existed), then the expiry check fires — returns "Code has expired" var unauthorized = result.Should().BeOfType().Subject; var error = unauthorized.Value.Should().BeOfType().Subject; error.Message.Should().Contain("expired"); @@ -109,7 +121,7 @@ public void ExchangeCode_ShouldReturn401_WhenCodeHasExpired() [Fact] public void GetProviders_ShouldReturnGitHubStatus() { - var controller = CreateAuthController(gitHubConfigured: true); + var (controller, _) = CreateAuthControllerWithUnitOfWork(gitHubConfigured: true); var result = controller.GetProviders(); var ok = result.Should().BeOfType().Subject; @@ -123,7 +135,7 @@ public void GetProviders_ShouldReturnGitHubStatus() [Fact] public void GitHubLogin_ShouldReturn404_WhenNotConfigured() { - var controller = CreateAuthController(gitHubConfigured: false); + var (controller, _) = CreateAuthControllerWithUnitOfWork(gitHubConfigured: false); var result = controller.GitHubLogin(); var notFound = result.Should().BeOfType().Subject; @@ -134,10 +146,8 @@ public void GitHubLogin_ShouldReturn404_WhenNotConfigured() [Fact] public void GitHubLogin_ShouldReturn400_WhenReturnUrlIsExternal() { - var controller = CreateAuthController(gitHubConfigured: true); + var (controller, _) = CreateAuthControllerWithUnitOfWork(gitHubConfigured: true); - // The controller calls Url.IsLocalUrl which needs ActionContext setup. - // We set up a mock URL helper that rejects external URLs. var urlHelper = new Mock(); urlHelper.Setup(u => u.IsLocalUrl("https://evil.com/steal")).Returns(false); controller.Url = urlHelper.Object; @@ -162,7 +172,6 @@ public async Task TokenValidationMiddleware_ShouldReturn401_WhenUserDeletedDurin var userRepoMock = new Mock(); unitOfWorkMock.Setup(u => u.Users).Returns(userRepoMock.Object); - // User is deleted — returns null userRepoMock.Setup(r => r.GetByIdAsync(userId, It.IsAny())) .ReturnsAsync((User?)null); @@ -189,10 +198,7 @@ public async Task TokenValidationMiddleware_ShouldReturn401_WhenTokenIssuedBefor var user = new User("testuser", "test@example.com", BCrypt.Net.BCrypt.HashPassword("password")); SetUserId(user, userId); - // Token was issued 2 hours ago var tokenIssuedAt = DateTimeOffset.UtcNow.AddHours(-2); - - // Invalidation happened 1 hour ago user.InvalidateTokens(); var unitOfWorkMock = new Mock(); @@ -220,7 +226,6 @@ public async Task TokenValidationMiddleware_ShouldReturn401_WhenTokenIssuedBefor [Fact] public async Task TokenValidationMiddleware_ShouldPassThrough_WhenTokenIssuedAfterReauthentication() { - // After invalidation, a freshly issued token should still work var userId = Guid.NewGuid(); var user = new User("testuser", "test@example.com", BCrypt.Net.BCrypt.HashPassword("password")); SetUserId(user, userId); @@ -237,7 +242,6 @@ public async Task TokenValidationMiddleware_ShouldPassThrough_WhenTokenIssuedAft RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; }; var middleware = new TokenValidationMiddleware(next, NullLogger.Instance); - // Token issued 2 seconds after invalidation var tokenIssuedAt = DateTimeOffset.UtcNow.AddSeconds(2); var context = CreateAuthenticatedContext(userId, tokenIssuedAt); @@ -249,7 +253,6 @@ public async Task TokenValidationMiddleware_ShouldPassThrough_WhenTokenIssuedAft [Fact] public async Task TokenValidationMiddleware_ShouldPassThrough_WhenClaimsHaveNoUserId() { - // Token with authenticated identity but no parseable userId var claims = new List { new("username", "testuser") }; var identity = new ClaimsIdentity(claims, "Bearer"); var principal = new ClaimsPrincipal(identity); @@ -263,7 +266,6 @@ public async Task TokenValidationMiddleware_ShouldPassThrough_WhenClaimsHaveNoUs await middleware.InvokeAsync(context, unitOfWorkMock.Object); - // Should pass through — let downstream handle it nextCalled.Should().BeTrue(); } @@ -274,8 +276,8 @@ public async Task TokenValidationMiddleware_ShouldPassThrough_WhenClaimsHaveNoUs [Fact] public async Task Login_ShouldReturn401_WhenBodyIsNull() { - var authService = CreateMockAuthService(); - var controller = new AuthController(authService.Object, CreateGitHubSettings(false), CreateMockUserContext().Object); + var (uow, authService) = CreateMockAuthServiceWithUow(); + var controller = new AuthController(authService.Object, CreateGitHubSettings(false), CreateMockUserContext().Object, uow.Object); var result = await controller.Login(null); @@ -287,8 +289,8 @@ public async Task Login_ShouldReturn401_WhenBodyIsNull() [Fact] public async Task Login_ShouldReturn401_WhenFieldsEmpty() { - var authService = CreateMockAuthService(); - var controller = new AuthController(authService.Object, CreateGitHubSettings(false), CreateMockUserContext().Object); + var (uow, authService) = CreateMockAuthServiceWithUow(); + var controller = new AuthController(authService.Object, CreateGitHubSettings(false), CreateMockUserContext().Object, uow.Object); var result = await controller.Login(new LoginDto("", "")); @@ -301,22 +303,30 @@ public async Task Login_ShouldReturn401_WhenFieldsEmpty() // Helpers // ───────────────────────────────────────────────────────── - private static AuthController CreateAuthController(bool gitHubConfigured = false) + private static (AuthController Controller, Mock UnitOfWork) CreateAuthControllerWithUnitOfWork(bool gitHubConfigured = false) { - var authServiceMock = CreateMockAuthService(); + var (unitOfWorkMock, authServiceMock) = CreateMockAuthServiceWithUow(); var gitHubSettings = CreateGitHubSettings(gitHubConfigured); - return new AuthController(authServiceMock.Object, gitHubSettings, CreateMockUserContext().Object); + + // Set up default OAuthAuthCodes repo that returns null (code not found) + var authCodeRepoMock = new Mock(); + authCodeRepoMock.Setup(r => r.GetByCodeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((OAuthAuthCode?)null); + unitOfWorkMock.Setup(u => u.OAuthAuthCodes).Returns(authCodeRepoMock.Object); + + var controller = new AuthController(authServiceMock.Object, gitHubSettings, CreateMockUserContext().Object, unitOfWorkMock.Object); + return (controller, unitOfWorkMock); } - private static Mock CreateMockAuthService() + private static (Mock UnitOfWork, Mock AuthService) CreateMockAuthServiceWithUow() { var unitOfWorkMock = new Mock(); var userRepoMock = new Mock(); unitOfWorkMock.Setup(u => u.Users).Returns(userRepoMock.Object); unitOfWorkMock.Setup(u => u.ExternalLogins).Returns(new Mock().Object); - // AuthenticationService is not sealed, but its constructor requires specific params - return new Mock(unitOfWorkMock.Object, DefaultJwtSettings) { CallBase = true }; + var authServiceMock = new Mock(unitOfWorkMock.Object, DefaultJwtSettings) { CallBase = true }; + return (unitOfWorkMock, authServiceMock); } private static Mock CreateMockUserContext() @@ -334,13 +344,13 @@ private static GitHubOAuthSettings CreateGitHubSettings(bool configured) : new GitHubOAuthSettings(); } - private static void InjectAuthCode(string code, AuthResultDto result, DateTimeOffset expiry) + private static OAuthAuthCode CreateExpiredAuthCode(string code) { - // Access the static _authCodes field via reflection - var field = typeof(AuthController).GetField("_authCodes", - BindingFlags.NonPublic | BindingFlags.Static); - var dict = (ConcurrentDictionary)field!.GetValue(null)!; - dict[code] = (result, expiry); + // Create a valid code first, then set ExpiresAt to the past via reflection + var authCode = new OAuthAuthCode(code, Guid.NewGuid(), "fake-token", DateTimeOffset.UtcNow.AddSeconds(60)); + var expiresAtProp = typeof(OAuthAuthCode).GetProperty("ExpiresAt"); + expiresAtProp!.SetValue(authCode, DateTimeOffset.UtcNow.AddSeconds(-10)); + return authCode; } private static DefaultHttpContext CreateAuthenticatedContext(Guid userId, DateTimeOffset? iat) diff --git a/backend/tests/Taskdeck.Api.Tests/LlmQueueToProposalWorkerTests.cs b/backend/tests/Taskdeck.Api.Tests/LlmQueueToProposalWorkerTests.cs index 827932f4e..b04920b3f 100644 --- a/backend/tests/Taskdeck.Api.Tests/LlmQueueToProposalWorkerTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/LlmQueueToProposalWorkerTests.cs @@ -843,6 +843,7 @@ public FakeUnitOfWork(ILlmQueueRepository llmQueue) public IKnowledgeDocumentRepository KnowledgeDocuments => null!; public IKnowledgeChunkRepository KnowledgeChunks => null!; public IExternalLoginRepository ExternalLogins => null!; + public IOAuthAuthCodeRepository OAuthAuthCodes => null!; public IApiKeyRepository ApiKeys => null!; public Task SaveChangesAsync(CancellationToken cancellationToken = default) diff --git a/backend/tests/Taskdeck.Api.Tests/OAuthTokenLifecycleTests.cs b/backend/tests/Taskdeck.Api.Tests/OAuthTokenLifecycleTests.cs index e463da238..2f19d19cb 100644 --- a/backend/tests/Taskdeck.Api.Tests/OAuthTokenLifecycleTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/OAuthTokenLifecycleTests.cs @@ -1,27 +1,30 @@ -using System.Collections.Concurrent; using System.IdentityModel.Tokens.Jwt; using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; -using System.Reflection; using System.Security.Claims; using System.Text; using System.Text.Json; using FluentAssertions; using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; -using Taskdeck.Api.Controllers; using Taskdeck.Api.Tests.Support; using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Entities; +using Taskdeck.Infrastructure.Persistence; using Xunit; namespace Taskdeck.Api.Tests; /// /// Integration tests for OAuth auth code store behavior and JWT token lifecycle. -/// Covers scenarios from #723 (TST-55): static auth code store, token issuance, +/// Covers scenarios from #723 (TST-55): DB-backed auth code store, token issuance, /// validation, expiration, wrong-key rejection, deactivated user, SignalR query-string auth, /// and code cleanup semantics. +/// Updated for #676: auth codes now stored in SQLite via OAuthAuthCode entity. /// public class OAuthTokenLifecycleTests : IClassFixture { @@ -42,7 +45,7 @@ public async Task ExchangeCode_ValidCode_ReturnsJwtAndConsumesCode() using var client = _factory.CreateClient(); var user = await ApiTestHarness.AuthenticateAsync(client, "oauth-valid"); - var code = InjectAuthCodeForUser(user, TimeSpan.FromSeconds(60)); + var code = await InjectAuthCodeForUser(user, TimeSpan.FromSeconds(60)); // Exchange code via the HTTP endpoint using var anonClient = _factory.CreateClient(); @@ -67,7 +70,7 @@ public async Task ExchangeCode_ExpiredCode_Returns401WithApiErrorShape() var user = await ApiTestHarness.AuthenticateAsync(client, "oauth-expired"); // Inject code that expired 10 seconds ago - var code = InjectAuthCodeForUser(user, TimeSpan.FromSeconds(-10)); + var code = await InjectAuthCodeForUser(user, TimeSpan.FromSeconds(-10)); using var anonClient = _factory.CreateClient(); var response = await anonClient.PostAsJsonAsync("/api/auth/github/exchange", new { Code = code }); @@ -102,7 +105,7 @@ public async Task ExchangeCode_ReplayPrevention_SecondCallFails() using var client = _factory.CreateClient(); var user = await ApiTestHarness.AuthenticateAsync(client, "oauth-replay"); - var code = InjectAuthCodeForUser(user, TimeSpan.FromSeconds(60)); + var code = await InjectAuthCodeForUser(user, TimeSpan.FromSeconds(60)); using var anonClient = _factory.CreateClient(); @@ -120,10 +123,9 @@ public async Task ExchangeCode_ConcurrentExchanges_OnlyOneSucceeds() using var client = _factory.CreateClient(); var user = await ApiTestHarness.AuthenticateAsync(client, "oauth-concurrent"); - var code = InjectAuthCodeForUser(user, TimeSpan.FromSeconds(60)); + var code = await InjectAuthCodeForUser(user, TimeSpan.FromSeconds(60)); // Fire multiple concurrent exchange requests for the same code. - // HttpClient is thread-safe, so reuse a single instance to avoid handler leaks. using var exchangeClient = _factory.CreateClient(); var tasks = Enumerable.Range(0, 5) .Select(_ => exchangeClient.PostAsJsonAsync("/api/auth/github/exchange", new { Code = code })) @@ -139,50 +141,30 @@ public async Task ExchangeCode_ConcurrentExchanges_OnlyOneSucceeds() } [Fact] - public void AuthCodeStore_CleanupRemovesExpiredCodes() + public async Task AuthCodeStore_ExpiredCodesCanBeCleanedUp() { - // Insert several codes: some expired, some valid + using var client = _factory.CreateClient(); + var user = await ApiTestHarness.AuthenticateAsync(client, "oauth-cleanup"); + + // Insert expired codes directly into the database + using var scope = _factory.Services.CreateScope(); + var uow = scope.ServiceProvider.GetRequiredService(); + var expiredCode1 = $"cleanup-expired-1-{Guid.NewGuid():N}"; var expiredCode2 = $"cleanup-expired-2-{Guid.NewGuid():N}"; var validCode = $"cleanup-valid-{Guid.NewGuid():N}"; - var dummyResult = CreateDummyAuthResult(); - - var dict = GetAuthCodesDict(); - dict[expiredCode1] = (dummyResult, DateTimeOffset.UtcNow.AddSeconds(-30)); - dict[expiredCode2] = (dummyResult, DateTimeOffset.UtcNow.AddSeconds(-5)); - dict[validCode] = (dummyResult, DateTimeOffset.UtcNow.AddSeconds(60)); - - // Trigger cleanup by calling CleanupExpiredCodes via reflection - var cleanupMethod = typeof(AuthController).GetMethod("CleanupExpiredCodes", - BindingFlags.NonPublic | BindingFlags.Static); - cleanupMethod!.Invoke(null, null); - - dict.ContainsKey(expiredCode1).Should().BeFalse("expired code should be cleaned up"); - dict.ContainsKey(expiredCode2).Should().BeFalse("expired code should be cleaned up"); - dict.ContainsKey(validCode).Should().BeTrue("valid code should survive cleanup"); - - // Clean up the valid code to avoid cross-test interference - dict.TryRemove(validCode, out _); - } - - [Fact] - public void AuthCodeStore_CleanupOnlyTriggeredDuringExchange_ExpiredCodesAccumulate() - { - // Document the current behavior: expired codes persist until CleanupExpiredCodes is called. - // This test validates the behavior described in the issue: cleanup is not on a timer. - // The static ConcurrentDictionary also does not work with horizontal scaling — see #676. - var code = $"accumulate-{Guid.NewGuid():N}"; - var dict = GetAuthCodesDict(); - var dummyResult = CreateDummyAuthResult(); - - dict[code] = (dummyResult, DateTimeOffset.UtcNow.AddSeconds(-60)); + await InsertAuthCodeDirectly(scope, expiredCode1, user.UserId, user.Token, DateTimeOffset.UtcNow.AddSeconds(-30)); + await InsertAuthCodeDirectly(scope, expiredCode2, user.UserId, user.Token, DateTimeOffset.UtcNow.AddSeconds(-5)); + await InsertAuthCodeDirectly(scope, validCode, user.UserId, user.Token, DateTimeOffset.UtcNow.AddSeconds(60)); - // Without an exchange attempt, the expired code remains in the dictionary - dict.ContainsKey(code).Should().BeTrue("expired codes accumulate until cleanup is triggered"); + // Run cleanup + var deleted = await uow.OAuthAuthCodes.DeleteExpiredAsync(DateTimeOffset.UtcNow); + deleted.Should().BeGreaterThanOrEqualTo(2, "at least the two expired codes should be deleted"); - // Clean up - dict.TryRemove(code, out _); + // The valid code should still be retrievable + var remainingCode = await uow.OAuthAuthCodes.GetByCodeAsync(validCode); + remainingCode.Should().NotBeNull("valid code should survive cleanup"); } // ───────────────────────────────────────────────────────── @@ -195,7 +177,6 @@ public async Task ExpiredJwt_Returns401WithApiErrorResponseShape() using var client = _factory.CreateClient(); var user = await ApiTestHarness.AuthenticateAsync(client, "tok-expired"); - // Create an already-expired JWT var expiredToken = CreateCustomJwt( userId: user.UserId, username: user.Username, @@ -211,7 +192,6 @@ public async Task ExpiredJwt_Returns401WithApiErrorResponseShape() var response = await expiredClient.GetAsync("/api/boards"); response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - // Verify the response is JSON ApiErrorResponse, not HTML await ApiTestHarness.AssertErrorContractAsync(response, HttpStatusCode.Unauthorized); } @@ -221,7 +201,6 @@ public async Task WrongSigningKey_Returns401WithApiErrorResponseShape() using var client = _factory.CreateClient(); var user = await ApiTestHarness.AuthenticateAsync(client, "tok-wrongkey"); - // Sign with a completely different secret key var wrongKeyToken = CreateCustomJwt( userId: user.UserId, username: user.Username, @@ -269,14 +248,9 @@ public async Task DeactivatedUser_Returns401() using var client = _factory.CreateClient(); var user = await ApiTestHarness.AuthenticateAsync(client, "tok-deactivated"); - // Deactivate the user account via the self-scope endpoint. - // After deactivation, the TokenValidationMiddleware should reject - // the original token because user.IsActive is false. var deactivateResponse = await client.PostAsync($"/api/users/{user.UserId}/deactivate", null); deactivateResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); - // The original token is still structurally valid (not expired, correct key), - // but the middleware rejects it because the user is inactive. var response = await client.GetAsync("/api/boards"); response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); @@ -289,7 +263,6 @@ public async Task ReissuedTokenAfterPasswordChange_CanAccessEndpoint() using var client = _factory.CreateClient(); var user = await ApiTestHarness.AuthenticateAsync(client, "tok-reissue"); - // Change password var changePwdResponse = await client.PostAsJsonAsync("/api/auth/change-password", new { CurrentPassword = "password123", @@ -297,7 +270,6 @@ public async Task ReissuedTokenAfterPasswordChange_CanAccessEndpoint() }); changePwdResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); - // Login with the new password to get a fresh token using var freshClient = _factory.CreateClient(); var loginResponse = await freshClient.PostAsJsonAsync("/api/auth/login", new { @@ -308,7 +280,6 @@ public async Task ReissuedTokenAfterPasswordChange_CanAccessEndpoint() var freshAuth = await loginResponse.Content.ReadFromJsonAsync(); freshClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", freshAuth!.Token); - // Fresh token should work var response = await freshClient.GetAsync("/api/boards"); response.StatusCode.Should().Be(HttpStatusCode.OK); } @@ -316,12 +287,6 @@ public async Task ReissuedTokenAfterPasswordChange_CanAccessEndpoint() // ───────────────────────────────────────────────────────── // 3. SignalR auth // ───────────────────────────────────────────────────────── - // Note: The .NET SignalR client with HttpMessageHandlerFactory (in-process - // test transport) sends tokens via the Authorization header, not via the - // ?access_token= query string. These tests verify SignalR endpoint auth - // generally but do NOT exercise the OnMessageReceived query-string - // extraction path in AuthenticationRegistration.cs. True query-string - // auth testing would require WebSocket transport or manual URL construction. [Fact] public async Task SignalR_AcceptsValidJwt() @@ -386,7 +351,6 @@ await act.Should().ThrowAsync() [Fact] public async Task GitHubLogin_WhenNotConfigured_Returns404() { - // The test factory does not configure GitHub OAuth secrets using var client = _factory.CreateClient(); var response = await client.GetAsync("/api/auth/github/login"); @@ -410,35 +374,78 @@ public async Task Providers_ReturnsGitHubStatus() } // ───────────────────────────────────────────────────────── - // Helpers + // 5. Account linking endpoints // ───────────────────────────────────────────────────────── - private static string InjectAuthCodeForUser(TestUserContext user, TimeSpan validFor) + [Fact] + public async Task LinkedAccounts_ReturnsEmptyListForNewUser() { - var code = $"test-code-{Guid.NewGuid():N}"; - var authResult = new AuthResultDto(user.Token, new UserDto( - user.UserId, user.Username, user.Email, - Domain.Enums.UserRole.Editor, true, - DateTimeOffset.UtcNow, DateTimeOffset.UtcNow)); + using var client = _factory.CreateClient(); + await ApiTestHarness.AuthenticateAsync(client, "link-empty"); - var dict = GetAuthCodesDict(); - dict[code] = (authResult, DateTimeOffset.UtcNow.Add(validFor)); - return code; + var response = await client.GetAsync("/api/auth/linked-accounts"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var accounts = await response.Content.ReadFromJsonAsync(); + accounts.Should().NotBeNull(); + accounts!.Should().BeEmpty(); } - private static ConcurrentDictionary GetAuthCodesDict() + [Fact] + public async Task UnlinkGitHub_Returns404_WhenNotLinked() { - var field = typeof(AuthController).GetField("_authCodes", - BindingFlags.NonPublic | BindingFlags.Static); - return (ConcurrentDictionary)field!.GetValue(null)!; + using var client = _factory.CreateClient(); + await ApiTestHarness.AuthenticateAsync(client, "unlink-none"); + + var response = await client.DeleteAsync("/api/auth/github/link"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task LinkGitHub_Returns401_WhenNotAuthenticated() + { + using var client = _factory.CreateClient(); + + var response = await client.PostAsJsonAsync("/api/auth/github/link", new { Code = "test" }); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + // ───────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────── + + private async Task InjectAuthCodeForUser(TestUserContext user, TimeSpan validFor) + { + var code = $"test-code-{Guid.NewGuid():N}"; + var expiresAt = DateTimeOffset.UtcNow.Add(validFor); + + using var scope = _factory.Services.CreateScope(); + await InsertAuthCodeDirectly(scope, code, user.UserId, user.Token, expiresAt); + + return code; } - private static AuthResultDto CreateDummyAuthResult() + private static async Task InsertAuthCodeDirectly(IServiceScope scope, string code, Guid userId, string token, DateTimeOffset expiresAt) { - return new AuthResultDto("dummy-token", new UserDto( - Guid.NewGuid(), "dummy", "dummy@test.com", - Domain.Enums.UserRole.Editor, true, - DateTimeOffset.UtcNow, DateTimeOffset.UtcNow)); + var db = scope.ServiceProvider.GetRequiredService(); + + // Use raw SQL to bypass the domain entity validation (which rejects past expiry dates) + await db.Database.ExecuteSqlRawAsync( + "INSERT INTO OAuthAuthCodes (Id, Code, UserId, Token, Purpose, ProviderData, ExpiresAt, IsConsumed, ConsumedAt, CreatedAt, UpdatedAt) " + + "VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}, {8}, {9}, {10})", + Guid.NewGuid().ToString(), + code, + userId.ToString(), + token, + "login", + (string?)null!, + expiresAt.ToString("o"), + false, + (string?)null!, + DateTimeOffset.UtcNow.ToString("o"), + DateTimeOffset.UtcNow.ToString("o")); } private static string CreateCustomJwt( @@ -465,8 +472,6 @@ private static string CreateCustomJwt( ClaimValueTypes.Integer64) }; - // For expired tokens: set notBefore far in the past and expires in the past. - // For valid tokens: notBefore = now, expires = now + expiresIn. var notBefore = expiresIn < TimeSpan.Zero ? now.AddMinutes(-60) : now; diff --git a/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerReliabilityTests.cs b/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerReliabilityTests.cs index 275b67446..7eb2d4a7b 100644 --- a/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerReliabilityTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerReliabilityTests.cs @@ -642,6 +642,7 @@ public FakeUnitOfWork(IOutboundWebhookDeliveryRepository deliveries) public IKnowledgeDocumentRepository KnowledgeDocuments => null!; public IKnowledgeChunkRepository KnowledgeChunks => null!; public IExternalLoginRepository ExternalLogins => null!; + public IOAuthAuthCodeRepository OAuthAuthCodes => null!; public IApiKeyRepository ApiKeys => null!; public Task SaveChangesAsync(CancellationToken cancellationToken = default) diff --git a/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerTests.cs b/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerTests.cs index f6a004ab3..000b8120e 100644 --- a/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerTests.cs @@ -548,6 +548,7 @@ public FakeUnitOfWork(IOutboundWebhookDeliveryRepository outboundWebhookDelivery public IKnowledgeDocumentRepository KnowledgeDocuments => null!; public IKnowledgeChunkRepository KnowledgeChunks => null!; public IExternalLoginRepository ExternalLogins => null!; + public IOAuthAuthCodeRepository OAuthAuthCodes => null!; public IApiKeyRepository ApiKeys => null!; public Task SaveChangesAsync(CancellationToken cancellationToken = default) diff --git a/backend/tests/Taskdeck.Api.Tests/OutboundWebhookHmacDeliveryTests.cs b/backend/tests/Taskdeck.Api.Tests/OutboundWebhookHmacDeliveryTests.cs index 3c1701121..f45c2bca1 100644 --- a/backend/tests/Taskdeck.Api.Tests/OutboundWebhookHmacDeliveryTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/OutboundWebhookHmacDeliveryTests.cs @@ -535,6 +535,7 @@ public StubUnitOfWork(IOutboundWebhookDeliveryRepository deliveries) public IKnowledgeDocumentRepository KnowledgeDocuments => null!; public IKnowledgeChunkRepository KnowledgeChunks => null!; public IExternalLoginRepository ExternalLogins => null!; + public IOAuthAuthCodeRepository OAuthAuthCodes => null!; public IApiKeyRepository ApiKeys => null!; public Task SaveChangesAsync(CancellationToken cancellationToken = default) diff --git a/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerEdgeCaseTests.cs b/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerEdgeCaseTests.cs index eff5025cb..77f4f7264 100644 --- a/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerEdgeCaseTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerEdgeCaseTests.cs @@ -341,6 +341,7 @@ public FakeUnitOfWork(IAutomationProposalRepository repo) public IKnowledgeDocumentRepository KnowledgeDocuments => null!; public IKnowledgeChunkRepository KnowledgeChunks => null!; public IExternalLoginRepository ExternalLogins => null!; + public IOAuthAuthCodeRepository OAuthAuthCodes => null!; public IApiKeyRepository ApiKeys => null!; public Task SaveChangesAsync(CancellationToken cancellationToken = default) diff --git a/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerTests.cs b/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerTests.cs index 302f91ab7..6b4226ae3 100644 --- a/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerTests.cs @@ -219,6 +219,7 @@ public FakeUnitOfWork(IAutomationProposalRepository automationProposalRepository public IKnowledgeDocumentRepository KnowledgeDocuments => null!; public IKnowledgeChunkRepository KnowledgeChunks => null!; public IExternalLoginRepository ExternalLogins => null!; + public IOAuthAuthCodeRepository OAuthAuthCodes => null!; public IApiKeyRepository ApiKeys => null!; public Task SaveChangesAsync(CancellationToken cancellationToken = default) diff --git a/backend/tests/Taskdeck.Api.Tests/WorkerResilienceTests.cs b/backend/tests/Taskdeck.Api.Tests/WorkerResilienceTests.cs index fd3f809ef..8d4f99299 100644 --- a/backend/tests/Taskdeck.Api.Tests/WorkerResilienceTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/WorkerResilienceTests.cs @@ -595,6 +595,7 @@ private sealed class FakeUnitOfWorkWithLlmQueue : IUnitOfWork public IKnowledgeDocumentRepository KnowledgeDocuments => null!; public IKnowledgeChunkRepository KnowledgeChunks => null!; public IExternalLoginRepository ExternalLogins => null!; + public IOAuthAuthCodeRepository OAuthAuthCodes => null!; public IApiKeyRepository ApiKeys => null!; public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(0); public Task BeginTransactionAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; @@ -630,6 +631,7 @@ private sealed class FakeUnitOfWorkWithProposals : IUnitOfWork public IKnowledgeDocumentRepository KnowledgeDocuments => null!; public IKnowledgeChunkRepository KnowledgeChunks => null!; public IExternalLoginRepository ExternalLogins => null!; + public IOAuthAuthCodeRepository OAuthAuthCodes => null!; public IApiKeyRepository ApiKeys => null!; public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(0); public Task BeginTransactionAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; From bb922479a5766af1338cdb8a44e23b998d085fcd Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:15:39 +0100 Subject: [PATCH 06/19] Enable PKCE for GitHub OAuth flow Sets UsePkce = true on the GitHub OAuth handler. ASP.NET Core 8 automatically generates code_verifier and sends code_challenge in the authorization request, then includes code_verifier in the token exchange. Defense-in-depth against authorization code interception. Part of #676. --- .../Taskdeck.Api/Extensions/AuthenticationRegistration.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/src/Taskdeck.Api/Extensions/AuthenticationRegistration.cs b/backend/src/Taskdeck.Api/Extensions/AuthenticationRegistration.cs index 88ad65467..f994c1c54 100644 --- a/backend/src/Taskdeck.Api/Extensions/AuthenticationRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/AuthenticationRegistration.cs @@ -98,6 +98,11 @@ await context.Response.WriteAsJsonAsync(new ApiErrorResponse( options.CallbackPath = "/api/auth/github/oauth-redirect"; options.SaveTokens = false; + // PKCE (Proof Key for Code Exchange) — defense-in-depth against + // authorization code interception attacks. GitHub supports PKCE + // and ASP.NET Core 8+ handles code_verifier/code_challenge automatically. + options.UsePkce = true; + options.Scope.Add("read:user"); options.Scope.Add("user:email"); From 99f530568c15c4e6840f663b81d711ea602ef4bb Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:17:41 +0100 Subject: [PATCH 07/19] Add GitHub account linking UI in settings page - LinkedAccount type and API methods (link, unlink, getLinkedAccounts) - ProfileSettingsView: shows linked GitHub account with avatar and display name, Link/Unlink buttons, handles oauth_link_code from redirect callback - Only shown when GitHub OAuth is configured and not in demo mode Part of #676. --- frontend/taskdeck-web/src/api/authApi.ts | 16 +- frontend/taskdeck-web/src/types/auth.ts | 8 + .../src/views/ProfileSettingsView.vue | 191 +++++++++++++++++- 3 files changed, 213 insertions(+), 2 deletions(-) diff --git a/frontend/taskdeck-web/src/api/authApi.ts b/frontend/taskdeck-web/src/api/authApi.ts index 7bcaaaf31..183737e07 100644 --- a/frontend/taskdeck-web/src/api/authApi.ts +++ b/frontend/taskdeck-web/src/api/authApi.ts @@ -1,5 +1,5 @@ import http from './http' -import type { LoginRequest, RegisterRequest, ChangePasswordRequest, AuthResponse, AuthProviders } from '../types/auth' +import type { LoginRequest, RegisterRequest, ChangePasswordRequest, AuthResponse, AuthProviders, LinkedAccount } from '../types/auth' export const authApi = { async login(credentials: LoginRequest): Promise { @@ -25,4 +25,18 @@ export const authApi = { const { data } = await http.post('/auth/github/exchange', { code }) return data }, + + async getLinkedAccounts(): Promise { + const { data } = await http.get('/auth/linked-accounts') + return data + }, + + async linkGitHub(code: string): Promise { + const { data } = await http.post('/auth/github/link', { code }) + return data + }, + + async unlinkGitHub(): Promise { + await http.delete('/auth/github/link') + }, } diff --git a/frontend/taskdeck-web/src/types/auth.ts b/frontend/taskdeck-web/src/types/auth.ts index 2cf4c2a53..b19b20d8a 100644 --- a/frontend/taskdeck-web/src/types/auth.ts +++ b/frontend/taskdeck-web/src/types/auth.ts @@ -42,3 +42,11 @@ export interface SessionState { export interface AuthProviders { gitHub: boolean } + +export interface LinkedAccount { + provider: string + providerUserId: string + displayName: string | null + avatarUrl: string | null + linkedAt: string +} diff --git a/frontend/taskdeck-web/src/views/ProfileSettingsView.vue b/frontend/taskdeck-web/src/views/ProfileSettingsView.vue index 24cfaacbe..c3ea717ee 100644 --- a/frontend/taskdeck-web/src/views/ProfileSettingsView.vue +++ b/frontend/taskdeck-web/src/views/ProfileSettingsView.vue @@ -1,11 +1,17 @@