diff --git a/backend/src/Taskdeck.Api/Controllers/AuthController.cs b/backend/src/Taskdeck.Api/Controllers/AuthController.cs index 6c740f8d7..712b8e7e8 100644 --- a/backend/src/Taskdeck.Api/Controllers/AuthController.cs +++ b/backend/src/Taskdeck.Api/Controllers/AuthController.cs @@ -17,7 +17,7 @@ namespace Taskdeck.Api.Controllers; -public record ChangePasswordRequest(string CurrentPassword, string NewPassword); +public record ChangePasswordRequest(string CurrentPassword, string NewPassword, string? MfaCode = null); public record ExchangeCodeRequest(string Code); public record LinkExchangeRequest(string Code); @@ -32,13 +32,23 @@ public class AuthController : AuthenticatedControllerBase { private readonly AuthenticationService _authService; private readonly GitHubOAuthSettings _gitHubOAuthSettings; + private readonly OidcSettings _oidcSettings; + private readonly MfaService _mfaService; private readonly IUnitOfWork _unitOfWork; - public AuthController(AuthenticationService authService, GitHubOAuthSettings gitHubOAuthSettings, IUserContext userContext, IUnitOfWork unitOfWork) + public AuthController( + AuthenticationService authService, + GitHubOAuthSettings gitHubOAuthSettings, + OidcSettings oidcSettings, + MfaService mfaService, + IUserContext userContext, + IUnitOfWork unitOfWork) : base(userContext) { _authService = authService; _gitHubOAuthSettings = gitHubOAuthSettings; + _oidcSettings = oidcSettings; + _mfaService = mfaService; _unitOfWork = unitOfWork; } @@ -93,11 +103,13 @@ public async Task Register([FromBody] CreateUserDto dto) /// /// Change the password for the authenticated caller. /// The target user is always derived from the JWT — client-supplied user IDs are not accepted. + /// When MFA is enabled and RequireMfaForSensitiveActions is true, a valid MFA code is required. /// - /// Current and new password. + /// Current password, new password, and optional MFA code. /// Password changed successfully. /// Validation error. /// Not authenticated or current password is incorrect. + /// MFA verification required but not provided or invalid. /// Rate limit exceeded. [HttpPost("change-password")] [Authorize] @@ -105,12 +117,26 @@ public async Task Register([FromBody] CreateUserDto dto) [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] public async Task ChangePassword([FromBody] ChangePasswordRequest request) { if (!TryGetCurrentUserId(out var callerUserId, out var errorResult)) return errorResult!; + // Enforce MFA for sensitive actions when policy requires it + if (await _mfaService.IsMfaRequiredForSensitiveActionAsync(callerUserId)) + { + if (string.IsNullOrWhiteSpace(request.MfaCode)) + return StatusCode(StatusCodes.Status403Forbidden, new ApiErrorResponse( + ErrorCodes.Forbidden, "MFA verification is required for this action")); + + var mfaResult = await _mfaService.VerifyCodeAsync(callerUserId, request.MfaCode); + if (!mfaResult.IsSuccess) + return StatusCode(StatusCodes.Status403Forbidden, new ApiErrorResponse( + ErrorCodes.AuthenticationFailed, "Invalid MFA verification code")); + } + var result = await _authService.ChangePasswordAsync(callerUserId, request.CurrentPassword, request.NewPassword); return result.IsSuccess ? NoContent() : result.ToErrorActionResult(); } @@ -447,17 +473,166 @@ public async Task GetLinkedAccounts() } /// - /// Returns whether GitHub OAuth login is available on this instance. + /// Returns available authentication providers on this instance. /// [HttpGet("providers")] public IActionResult GetProviders() { + var oidcProviders = _oidcSettings.ConfiguredProviders + .Select(p => new OidcProviderInfoDto(p.Name, p.DisplayName)) + .ToList(); + return Ok(new { - GitHub = _gitHubOAuthSettings.IsConfigured + GitHub = _gitHubOAuthSettings.IsConfigured, + Oidc = oidcProviders }); } + /// + /// Initiates OIDC login flow for a named provider. Only available when the provider is configured. + /// + [HttpGet("oidc/{providerName}/login")] + [EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)] + public IActionResult OidcLogin(string providerName, [FromQuery] string? returnUrl = null) + { + var provider = _oidcSettings.ConfiguredProviders + .FirstOrDefault(p => string.Equals(p.Name, providerName, StringComparison.OrdinalIgnoreCase)); + + if (provider == null) + return NotFound(new ApiErrorResponse(ErrorCodes.NotFound, $"OIDC provider '{providerName}' is not configured")); + + if (!string.IsNullOrWhiteSpace(returnUrl) && !Url.IsLocalUrl(returnUrl)) + return BadRequest(new ApiErrorResponse(ErrorCodes.ValidationError, "Invalid return URL")); + + var schemeName = $"Oidc_{provider.Name}"; + var properties = new AuthenticationProperties + { + RedirectUri = Url.Action(nameof(OidcCallback), new { providerName = provider.Name, returnUrl }), + Items = { { "LoginProvider", provider.Name } } + }; + + return Challenge(properties, schemeName); + } + + /// + /// Handles the OIDC callback, creates/links the user, and redirects with a short-lived code. + /// + [HttpGet("oidc/{providerName}/callback")] + [EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)] + public async Task OidcCallback(string providerName, [FromQuery] string? returnUrl = null) + { + var provider = _oidcSettings.ConfiguredProviders + .FirstOrDefault(p => string.Equals(p.Name, providerName, StringComparison.OrdinalIgnoreCase)); + + if (provider == null) + return NotFound(new ApiErrorResponse(ErrorCodes.NotFound, $"OIDC provider '{providerName}' is not configured")); + + var schemeName = $"Oidc_{provider.Name}"; + var authenticateResult = await HttpContext.AuthenticateAsync(schemeName); + if (!authenticateResult.Succeeded || authenticateResult.Principal == null) + { + return Unauthorized(new ApiErrorResponse( + ErrorCodes.AuthenticationFailed, + $"OIDC authentication with '{provider.DisplayName}' failed")); + } + + var claims = authenticateResult.Principal.Claims.ToList(); + var providerUserId = claims.FirstOrDefault(c => c.Type == System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + var username = claims.FirstOrDefault(c => c.Type == System.Security.Claims.ClaimTypes.Name)?.Value + ?? claims.FirstOrDefault(c => c.Type == "preferred_username")?.Value; + var email = claims.FirstOrDefault(c => c.Type == System.Security.Claims.ClaimTypes.Email)?.Value; + var displayName = claims.FirstOrDefault(c => c.Type == "name")?.Value; + + if (string.IsNullOrWhiteSpace(providerUserId)) + { + return Unauthorized(new ApiErrorResponse( + ErrorCodes.AuthenticationFailed, + $"OIDC provider '{provider.DisplayName}' did not return a user identifier")); + } + + if (string.IsNullOrWhiteSpace(email)) + email = $"{provider.Name.ToLowerInvariant()}-{providerUserId}@external.taskdeck.local"; + + if (string.IsNullOrWhiteSpace(username)) + username = $"{provider.Name.ToLowerInvariant()}-user-{providerUserId}"; + + var dto = new ExternalLoginDto( + Provider: $"oidc_{provider.Name}", + ProviderUserId: providerUserId, + Username: username, + Email: email, + DisplayName: displayName, + AvatarUrl: null); + + var result = await _authService.ExternalLoginAsync(dto); + + if (!result.IsSuccess) + return result.ToErrorActionResult(); + + // Sign out the temporary cookie used during the OIDC handshake + await HttpContext.SignOutAsync(AuthenticationRegistration.ExternalAuthenticationScheme); + + // Store only the user ID in the auth code -- JWT is re-issued at exchange time. + var code = GenerateAuthCode(); + var authCode = new OAuthAuthCode( + code: code, + userId: result.Value.User.Id, + token: "placeholder", // Not stored; JWT re-issued at exchange + expiresAt: DateTimeOffset.UtcNow.AddSeconds(60)); + + await _unitOfWork.OAuthAuthCodes.AddAsync(authCode); + await _unitOfWork.SaveChangesAsync(); + + // Best-effort cleanup of expired/consumed codes + await CleanupExpiredCodesAsync(); + + var safeReturnUrl = !string.IsNullOrWhiteSpace(returnUrl) && Url.IsLocalUrl(returnUrl) + ? returnUrl + : "/"; + + var separator = safeReturnUrl.Contains('?') ? "&" : "?"; + return Redirect($"{safeReturnUrl}{separator}oauth_code={Uri.EscapeDataString(code)}&oauth_provider=oidc"); + } + + /// + /// Exchanges a short-lived OIDC authorization code for a JWT token. + /// Reuses the same database-backed code store as GitHub OAuth. + /// + [HttpPost("oidc/exchange")] + [EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)] + public async Task OidcExchangeCode([FromBody] ExchangeCodeRequest request) + { + const string genericError = "Invalid or expired code"; + + if (string.IsNullOrWhiteSpace(request.Code)) + return BadRequest(new ApiErrorResponse(ErrorCodes.ValidationError, "Code is required")); + + var authCode = await _unitOfWork.OAuthAuthCodes.GetByCodeAsync(request.Code); + if (authCode == null || authCode.IsLinkingCode || authCode.IsExpired || authCode.IsConsumed) + return Unauthorized(new ApiErrorResponse(ErrorCodes.AuthenticationFailed, genericError)); + + var consumed = await _unitOfWork.OAuthAuthCodes.TryConsumeAtomicAsync(request.Code); + if (!consumed) + return Unauthorized(new ApiErrorResponse(ErrorCodes.AuthenticationFailed, genericError)); + + var user = await _unitOfWork.Users.GetByIdAsync(authCode.UserId); + if (user == null) + return Unauthorized(new ApiErrorResponse(ErrorCodes.AuthenticationFailed, genericError)); + + var userDto = new UserDto( + user.Id, + user.Username, + user.Email, + user.DefaultRole, + user.IsActive, + user.CreatedAt, + user.UpdatedAt); + + var freshToken = _authService.GenerateJwtToken(user); + return Ok(new AuthResultDto(freshToken, userDto)); + } + private static string GenerateAuthCode() { var bytes = RandomNumberGenerator.GetBytes(32); diff --git a/backend/src/Taskdeck.Api/Controllers/MfaController.cs b/backend/src/Taskdeck.Api/Controllers/MfaController.cs new file mode 100644 index 000000000..4852f76df --- /dev/null +++ b/backend/src/Taskdeck.Api/Controllers/MfaController.cs @@ -0,0 +1,116 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Taskdeck.Api.Contracts; +using Taskdeck.Api.Extensions; +using Taskdeck.Api.RateLimiting; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Api.Controllers; + +/// +/// MFA setup, verification, and status endpoints. +/// All endpoints require authentication. MFA is optional and config-gated. +/// +[ApiController] +[Route("api/auth/mfa")] +[Authorize] +[Produces("application/json")] +public class MfaController : AuthenticatedControllerBase +{ + private readonly MfaService _mfaService; + + public MfaController(MfaService mfaService, IUserContext userContext) + : base(userContext) + { + _mfaService = mfaService; + } + + /// + /// Returns the current MFA status for the authenticated user. + /// + [HttpGet("status")] + [ProducesResponseType(typeof(MfaStatusDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + public async Task GetStatus() + { + if (!TryGetCurrentUserId(out var userId, out var errorResult)) + return errorResult!; + + var result = await _mfaService.GetStatusAsync(userId); + return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); + } + + /// + /// Initiates MFA setup. Returns the shared secret, QR code URI, and recovery codes. + /// The user must confirm setup by entering a valid TOTP code. + /// + [HttpPost("setup")] + [EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)] + [ProducesResponseType(typeof(MfaSetupDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)] + public async Task Setup() + { + if (!TryGetCurrentUserId(out var userId, out var errorResult)) + return errorResult!; + + var result = await _mfaService.SetupAsync(userId); + return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); + } + + /// + /// Confirms MFA setup by validating a TOTP code from the user's authenticator app. + /// + [HttpPost("confirm")] + [EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + public async Task ConfirmSetup([FromBody] MfaVerifyRequest request) + { + if (!TryGetCurrentUserId(out var userId, out var errorResult)) + return errorResult!; + + var result = await _mfaService.ConfirmSetupAsync(userId, request.Code); + return result.IsSuccess ? NoContent() : result.ToErrorActionResult(); + } + + /// + /// Verifies a TOTP code for a sensitive action gate. + /// + [HttpPost("verify")] + [EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + public async Task Verify([FromBody] MfaVerifyRequest request) + { + if (!TryGetCurrentUserId(out var userId, out var errorResult)) + return errorResult!; + + var result = await _mfaService.VerifyCodeAsync(userId, request.Code); + return result.IsSuccess ? NoContent() : result.ToErrorActionResult(); + } + + /// + /// Disables MFA for the authenticated user. Requires a valid TOTP code. + /// + [HttpPost("disable")] + [EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + public async Task Disable([FromBody] MfaVerifyRequest request) + { + if (!TryGetCurrentUserId(out var userId, out var errorResult)) + return errorResult!; + + var result = await _mfaService.DisableAsync(userId, request.Code); + return result.IsSuccess ? NoContent() : result.ToErrorActionResult(); + } +} diff --git a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs index 6c7040803..e16e2e86f 100644 --- a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs @@ -19,6 +19,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(); services.AddScoped(); diff --git a/backend/src/Taskdeck.Api/Extensions/AuthenticationRegistration.cs b/backend/src/Taskdeck.Api/Extensions/AuthenticationRegistration.cs index f994c1c54..49a20b695 100644 --- a/backend/src/Taskdeck.Api/Extensions/AuthenticationRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/AuthenticationRegistration.cs @@ -1,8 +1,11 @@ using System.Security.Claims; using System.Text; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.OAuth.Claims; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Http; using Microsoft.IdentityModel.Tokens; using Taskdeck.Api.Contracts; using Taskdeck.Application.Services; @@ -13,10 +16,16 @@ namespace Taskdeck.Api.Extensions; public static class AuthenticationRegistration { + /// + /// Cookie scheme used for temporary external auth state (OAuth/OIDC handshake). + /// + public const string ExternalAuthenticationScheme = "External"; + public static IServiceCollection AddTaskdeckAuthentication( this IServiceCollection services, JwtSettings jwtSettings, - GitHubOAuthSettings? gitHubOAuthSettings = null) + GitHubOAuthSettings? gitHubOAuthSettings = null, + OidcSettings? oidcSettings = null) { if (string.IsNullOrWhiteSpace(jwtSettings.SecretKey) || jwtSettings.SecretKey.Length < 32 || @@ -26,7 +35,21 @@ public static IServiceCollection AddTaskdeckAuthentication( return services; } - var authBuilder = services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + var authBuilder = services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultSignInScheme = ExternalAuthenticationScheme; + }) + .AddCookie(ExternalAuthenticationScheme, options => + { + options.Cookie.Name = ".Taskdeck.ExternalAuth"; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.ExpireTimeSpan = TimeSpan.FromMinutes(5); + options.SlidingExpiration = false; + }) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters @@ -90,6 +113,7 @@ await context.Response.WriteAsJsonAsync(new ApiErrorResponse( { authBuilder.AddOAuth("GitHub", options => { + options.SignInScheme = ExternalAuthenticationScheme; options.ClientId = gitHubOAuthSettings.ClientId; options.ClientSecret = gitHubOAuthSettings.ClientSecret; options.AuthorizationEndpoint = "https://github.com/login/oauth/authorize"; @@ -118,6 +142,42 @@ await context.Response.WriteAsJsonAsync(new ApiErrorResponse( }); } + // Environment-gated: add OIDC providers when configured + if (oidcSettings != null) + { + foreach (var provider in oidcSettings.ConfiguredProviders) + { + var schemeName = $"Oidc_{provider.Name}"; + var callbackPath = !string.IsNullOrWhiteSpace(provider.CallbackPath) + ? provider.CallbackPath + : $"/api/auth/oidc/{provider.Name.ToLowerInvariant()}/oauth-redirect"; + + authBuilder.AddOpenIdConnect(schemeName, provider.DisplayName, options => + { + options.SignInScheme = ExternalAuthenticationScheme; + options.Authority = provider.Authority; + options.ClientId = provider.ClientId; + options.ClientSecret = provider.ClientSecret; + options.CallbackPath = callbackPath; + options.ResponseType = "code"; + options.SaveTokens = false; + options.GetClaimsFromUserInfoEndpoint = true; + + options.Scope.Clear(); + foreach (var scope in provider.Scopes) + { + options.Scope.Add(scope); + } + + // Map standard OIDC claims to ClaimTypes + options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "sub"); + options.ClaimActions.MapJsonKey(ClaimTypes.Name, "preferred_username"); + options.ClaimActions.MapJsonKey(ClaimTypes.Email, "email"); + options.ClaimActions.MapJsonKey("name", "name"); + }); + } + } + return services; } diff --git a/backend/src/Taskdeck.Api/Extensions/SettingsRegistration.cs b/backend/src/Taskdeck.Api/Extensions/SettingsRegistration.cs index 35bcc3607..a0bca0a32 100644 --- a/backend/src/Taskdeck.Api/Extensions/SettingsRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/SettingsRegistration.cs @@ -12,6 +12,7 @@ public static IServiceCollection AddTaskdeckSettings( out RateLimitingSettings rateLimitingSettings, out JwtSettings jwtSettings, out GitHubOAuthSettings gitHubOAuthSettings, + out OidcSettings oidcSettings, out SentrySettings sentrySettings, out TelemetrySettings telemetrySettings, out AnalyticsSettings analyticsSettings) @@ -48,6 +49,12 @@ public static IServiceCollection AddTaskdeckSettings( gitHubOAuthSettings = configuration.GetSection("GitHubOAuth").Get() ?? new GitHubOAuthSettings(); services.AddSingleton(gitHubOAuthSettings); + oidcSettings = configuration.GetSection("Oidc").Get() ?? new OidcSettings(); + services.AddSingleton(oidcSettings); + + var mfaPolicySettings = configuration.GetSection("MfaPolicy").Get() ?? new MfaPolicySettings(); + services.AddSingleton(mfaPolicySettings); + var sandboxSettings = configuration.GetSection("DevelopmentSandbox").Get() ?? new DevelopmentSandboxSettings(); sandboxSettings.Enabled = sandboxSettings.Enabled && environment.IsDevelopment(); services.AddSingleton(sandboxSettings); diff --git a/backend/src/Taskdeck.Api/Program.cs b/backend/src/Taskdeck.Api/Program.cs index 2e4e3f8d3..601cf78ad 100644 --- a/backend/src/Taskdeck.Api/Program.cs +++ b/backend/src/Taskdeck.Api/Program.cs @@ -297,6 +297,7 @@ out var rateLimitingSettings, out var jwtSettings, out var gitHubOAuthSettings, + out var oidcSettings, out var sentrySettings, out _, // telemetrySettings — registered in DI by AddTaskdeckSettings out _); // analyticsSettings — registered in DI by AddTaskdeckSettings @@ -326,8 +327,8 @@ .WithTools() .WithTools(); -// Add JWT Authentication (with optional GitHub OAuth) -builder.Services.AddTaskdeckAuthentication(jwtSettings, gitHubOAuthSettings); +// Add JWT Authentication (with optional GitHub OAuth and OIDC providers) +builder.Services.AddTaskdeckAuthentication(jwtSettings, gitHubOAuthSettings, oidcSettings); // Add OpenTelemetry observability builder.Services.AddTaskdeckObservability(observabilitySettings); diff --git a/backend/src/Taskdeck.Api/Taskdeck.Api.csproj b/backend/src/Taskdeck.Api/Taskdeck.Api.csproj index 4b651b34e..949eb06a4 100644 --- a/backend/src/Taskdeck.Api/Taskdeck.Api.csproj +++ b/backend/src/Taskdeck.Api/Taskdeck.Api.csproj @@ -19,6 +19,7 @@ + all diff --git a/backend/src/Taskdeck.Application/DTOs/MfaDtos.cs b/backend/src/Taskdeck.Application/DTOs/MfaDtos.cs new file mode 100644 index 000000000..1eae67355 --- /dev/null +++ b/backend/src/Taskdeck.Application/DTOs/MfaDtos.cs @@ -0,0 +1,24 @@ +namespace Taskdeck.Application.DTOs; + +/// +/// Response for MFA setup initiation. Contains the shared secret and provisioning URI. +/// +public record MfaSetupDto( + string SharedSecret, + string QrCodeUri, + string[] RecoveryCodes); + +/// +/// Request to confirm MFA setup or verify an MFA challenge. +/// +public record MfaVerifyRequest(string Code); + +/// +/// Response when MFA verification is required before completing an action. +/// +public record MfaChallengeDto(string ChallengeToken, string Message); + +/// +/// Status of the user's MFA configuration. +/// +public record MfaStatusDto(bool IsEnabled, bool IsSetupAvailable); diff --git a/backend/src/Taskdeck.Application/DTOs/OidcDtos.cs b/backend/src/Taskdeck.Application/DTOs/OidcDtos.cs new file mode 100644 index 000000000..f07332562 --- /dev/null +++ b/backend/src/Taskdeck.Application/DTOs/OidcDtos.cs @@ -0,0 +1,9 @@ +namespace Taskdeck.Application.DTOs; + +/// +/// Information about a configured OIDC provider, exposed to the frontend. +/// Secrets are never included. +/// +public record OidcProviderInfoDto( + string Name, + string DisplayName); diff --git a/backend/src/Taskdeck.Application/Interfaces/IMfaCredentialRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IMfaCredentialRepository.cs new file mode 100644 index 000000000..5c17a226e --- /dev/null +++ b/backend/src/Taskdeck.Application/Interfaces/IMfaCredentialRepository.cs @@ -0,0 +1,9 @@ +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.Interfaces; + +public interface IMfaCredentialRepository : IRepository +{ + Task GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); + Task DeleteByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs b/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs index 9bd73ff9e..6704dcf54 100644 --- a/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs +++ b/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs @@ -29,6 +29,7 @@ public interface IUnitOfWork IExternalLoginRepository ExternalLogins { get; } IOAuthAuthCodeRepository OAuthAuthCodes { get; } IApiKeyRepository ApiKeys { get; } + IMfaCredentialRepository MfaCredentials { get; } Task SaveChangesAsync(CancellationToken cancellationToken = default); Task BeginTransactionAsync(CancellationToken cancellationToken = default); diff --git a/backend/src/Taskdeck.Application/Services/MfaPolicySettings.cs b/backend/src/Taskdeck.Application/Services/MfaPolicySettings.cs new file mode 100644 index 000000000..3dd9ed823 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/MfaPolicySettings.cs @@ -0,0 +1,35 @@ +namespace Taskdeck.Application.Services; + +/// +/// Configuration for MFA policy. Controls which actions require MFA verification. +/// MFA is always optional unless explicitly enabled by the administrator. +/// +public class MfaPolicySettings +{ + /// + /// Whether MFA setup is available to users. Does not force MFA on anyone. + /// + public bool EnableMfaSetup { get; set; } + + /// + /// When true, users who have MFA enabled will be required to verify + /// before performing sensitive actions (password change, account deletion). + /// + public bool RequireMfaForSensitiveActions { get; set; } + + /// + /// TOTP time step in seconds. Standard is 30. + /// + public int TotpTimeStepSeconds { get; set; } = 30; + + /// + /// Number of recovery codes to generate during MFA setup. + /// + public int RecoveryCodeCount { get; set; } = 8; + + /// + /// Number of adjacent time windows to accept for TOTP validation. + /// A value of 1 means current + 1 before + 1 after = 3 windows. + /// + public int TotpToleranceSteps { get; set; } = 1; +} diff --git a/backend/src/Taskdeck.Application/Services/MfaService.cs b/backend/src/Taskdeck.Application/Services/MfaService.cs new file mode 100644 index 000000000..4d8320231 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/MfaService.cs @@ -0,0 +1,374 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Application.Services; + +/// +/// Manages TOTP-based MFA setup, verification, and status. +/// +public class MfaService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly MfaPolicySettings _policySettings; + + // TOTP constants + private const int SecretByteLength = 20; // 160-bit secret per RFC 6238 + private const int TotpCodeLength = 6; + private const string TotpAlgorithm = "SHA1"; // Standard for Google Authenticator compatibility + private const string Issuer = "Taskdeck"; + + // TOTP replay protection: tracks recently used codes to prevent reuse within the validity window. + // Key: "userId:code:timeStep", Value: expiry. Cleaned up lazily. + private static readonly ConcurrentDictionary _usedCodes = new(); + + public MfaService(IUnitOfWork unitOfWork, MfaPolicySettings policySettings) + { + _unitOfWork = unitOfWork; + _policySettings = policySettings; + } + + /// + /// Returns the current MFA status for a user. + /// + public async Task> GetStatusAsync(Guid userId) + { + var user = await _unitOfWork.Users.GetByIdAsync(userId); + if (user == null) + return Result.Failure(ErrorCodes.NotFound, "User not found"); + + return Result.Success(new MfaStatusDto( + IsEnabled: user.MfaEnabled, + IsSetupAvailable: _policySettings.EnableMfaSetup)); + } + + /// + /// Initiates MFA setup by generating a new TOTP secret and recovery codes. + /// Returns the secret and QR code URI for the user to scan. + /// Any existing unconfirmed credential is replaced. + /// + public async Task> SetupAsync(Guid userId) + { + if (!_policySettings.EnableMfaSetup) + return Result.Failure(ErrorCodes.Forbidden, "MFA setup is not enabled on this instance"); + + var user = await _unitOfWork.Users.GetByIdAsync(userId); + if (user == null) + return Result.Failure(ErrorCodes.NotFound, "User not found"); + + if (user.MfaEnabled) + return Result.Failure(ErrorCodes.Conflict, "MFA is already enabled. Disable it first to set up again."); + + // Remove any existing unconfirmed credential + await _unitOfWork.MfaCredentials.DeleteByUserIdAsync(userId); + + // Generate new TOTP secret + var secretBytes = RandomNumberGenerator.GetBytes(SecretByteLength); + var secret = Base32Encode(secretBytes); + + // Generate recovery codes + var recoveryCodes = GenerateRecoveryCodes(_policySettings.RecoveryCodeCount); + var hashedRecoveryCodes = string.Join(",", recoveryCodes.Select(c => BCrypt.Net.BCrypt.HashPassword(c))); + + // Create and persist credential + var credential = new MfaCredential(userId, secret); + credential.SetRecoveryCodes(hashedRecoveryCodes); + await _unitOfWork.MfaCredentials.AddAsync(credential); + await _unitOfWork.SaveChangesAsync(); + + // Build provisioning URI (otpauth://totp/Issuer:username?secret=...&issuer=...&digits=6&period=30) + var qrCodeUri = $"otpauth://totp/{Uri.EscapeDataString(Issuer)}:{Uri.EscapeDataString(user.Username)}" + + $"?secret={secret}&issuer={Uri.EscapeDataString(Issuer)}&digits={TotpCodeLength}" + + $"&period={_policySettings.TotpTimeStepSeconds}"; + + return Result.Success(new MfaSetupDto(secret, qrCodeUri, recoveryCodes)); + } + + /// + /// Confirms MFA setup by validating a TOTP code from the user's authenticator app. + /// + public async Task ConfirmSetupAsync(Guid userId, string code) + { + if (string.IsNullOrWhiteSpace(code)) + return Result.Failure(ErrorCodes.ValidationError, "Verification code is required"); + + var credential = await _unitOfWork.MfaCredentials.GetByUserIdAsync(userId); + if (credential == null) + return Result.Failure(ErrorCodes.NotFound, "No MFA setup in progress. Initiate setup first."); + + if (credential.IsConfirmed) + return Result.Failure(ErrorCodes.Conflict, "MFA is already confirmed"); + + if (!ValidateTotp(credential.Secret, code, userId)) + return Result.Failure(ErrorCodes.AuthenticationFailed, "Invalid verification code. Please try again."); + + credential.Confirm(); + + var user = await _unitOfWork.Users.GetByIdAsync(userId); + if (user == null) + return Result.Failure(ErrorCodes.NotFound, "User not found"); + + user.EnableMfa(); + await _unitOfWork.SaveChangesAsync(); + + return Result.Success(); + } + + /// + /// Disables MFA for a user. Requires a valid TOTP code or recovery code for security. + /// + public async Task DisableAsync(Guid userId, string code) + { + if (string.IsNullOrWhiteSpace(code)) + return Result.Failure(ErrorCodes.ValidationError, "Verification code is required"); + + var user = await _unitOfWork.Users.GetByIdAsync(userId); + if (user == null) + return Result.Failure(ErrorCodes.NotFound, "User not found"); + + if (!user.MfaEnabled) + return Result.Failure(ErrorCodes.ValidationError, "MFA is not enabled"); + + var credential = await _unitOfWork.MfaCredentials.GetByUserIdAsync(userId); + if (credential == null) + return Result.Failure(ErrorCodes.NotFound, "MFA credential not found"); + + // Try TOTP code first, then fall back to recovery code + var totpValid = ValidateTotp(credential.Secret, code, userId); + var recoveryValid = !totpValid && TryUseRecoveryCode(credential, code); + + if (!totpValid && !recoveryValid) + return Result.Failure(ErrorCodes.AuthenticationFailed, "Invalid verification code"); + + user.DisableMfa(); + await _unitOfWork.MfaCredentials.DeleteByUserIdAsync(userId); + await _unitOfWork.SaveChangesAsync(); + + return Result.Success(); + } + + /// + /// Validates a TOTP code for an authenticated user (used for sensitive action gates). + /// + public async Task VerifyCodeAsync(Guid userId, string code) + { + if (string.IsNullOrWhiteSpace(code)) + return Result.Failure(ErrorCodes.ValidationError, "Verification code is required"); + + var user = await _unitOfWork.Users.GetByIdAsync(userId); + if (user == null) + return Result.Failure(ErrorCodes.NotFound, "User not found"); + + if (!user.MfaEnabled) + return Result.Failure(ErrorCodes.ValidationError, "MFA is not enabled for this user"); + + var credential = await _unitOfWork.MfaCredentials.GetByUserIdAsync(userId); + if (credential == null || !credential.IsConfirmed) + return Result.Failure(ErrorCodes.NotFound, "MFA credential not found or not confirmed"); + + // Try TOTP code first + if (ValidateTotp(credential.Secret, code, userId)) + return Result.Success(); + + // Try recovery code + if (TryUseRecoveryCode(credential, code)) + { + await _unitOfWork.SaveChangesAsync(); + return Result.Success(); + } + + return Result.Failure(ErrorCodes.AuthenticationFailed, "Invalid verification code"); + } + + /// + /// Checks whether MFA verification is required for a sensitive action. + /// + public async Task IsMfaRequiredForSensitiveActionAsync(Guid userId) + { + if (!_policySettings.RequireMfaForSensitiveActions) + return false; + + var user = await _unitOfWork.Users.GetByIdAsync(userId); + return user?.MfaEnabled == true; + } + + // ── TOTP Implementation ───────────────────────────────────────────── + + internal bool ValidateTotp(string base32Secret, string code, Guid? userId = null) + { + if (string.IsNullOrWhiteSpace(code) || code.Length != TotpCodeLength) + return false; + + // Constant-time comparison within tolerance window + var secretBytes = Base32Decode(base32Secret); + var timeStep = _policySettings.TotpTimeStepSeconds; + var currentStep = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / timeStep; + + for (var i = -_policySettings.TotpToleranceSteps; i <= _policySettings.TotpToleranceSteps; i++) + { + var step = currentStep + i; + var expectedCode = ComputeTotp(secretBytes, step); + if (!ConstantTimeEquals(code, expectedCode)) + continue; + + // Replay protection: reject codes already used in this time step + if (userId.HasValue) + { + var replayKey = $"{userId.Value}:{code}:{step}"; + if (_usedCodes.ContainsKey(replayKey)) + return false; + + // Mark code as used with expiry = 2 * tolerance * timeStep + var expirySeconds = (2 * _policySettings.TotpToleranceSteps + 1) * timeStep; + _usedCodes[replayKey] = DateTimeOffset.UtcNow.AddSeconds(expirySeconds); + CleanupExpiredUsedCodes(); + } + + return true; + } + + return false; + } + + private static void CleanupExpiredUsedCodes() + { + var now = DateTimeOffset.UtcNow; + foreach (var kvp in _usedCodes) + { + if (now > kvp.Value) + _usedCodes.TryRemove(kvp.Key, out _); + } + } + + private static string ComputeTotp(byte[] secret, long timeCounter) + { + var counterBytes = BitConverter.GetBytes(timeCounter); + if (BitConverter.IsLittleEndian) + Array.Reverse(counterBytes); + + using var hmac = new HMACSHA1(secret); + var hash = hmac.ComputeHash(counterBytes); + + var offset = hash[^1] & 0x0F; + var truncatedHash = (hash[offset] & 0x7F) << 24 + | (hash[offset + 1] & 0xFF) << 16 + | (hash[offset + 2] & 0xFF) << 8 + | (hash[offset + 3] & 0xFF); + + var code = truncatedHash % 1_000_000; + return code.ToString("D6"); + } + + private static bool ConstantTimeEquals(string a, string b) + { + if (a.Length != b.Length) + return false; + + var result = 0; + for (var i = 0; i < a.Length; i++) + result |= a[i] ^ b[i]; + + return result == 0; + } + + private bool TryUseRecoveryCode(MfaCredential credential, string code) + { + if (string.IsNullOrWhiteSpace(credential.RecoveryCodes) || credential.RecoveryCodes == "EXHAUSTED") + return false; + + var hashedCodes = credential.RecoveryCodes.Split(',').ToList(); + for (var i = 0; i < hashedCodes.Count; i++) + { + if (!BCrypt.Net.BCrypt.Verify(code, hashedCodes[i])) + continue; + + // Remove used recovery code + hashedCodes.RemoveAt(i); + credential.SetRecoveryCodes(hashedCodes.Count > 0 + ? string.Join(",", hashedCodes) + : null); // Clear recovery codes when all are exhausted + return true; + } + + return false; + } + + // ── Base32 Encoding/Decoding ──────────────────────────────────────── + + private static readonly char[] Base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".ToCharArray(); + + internal static string Base32Encode(byte[] data) + { + var result = new char[(data.Length * 8 + 4) / 5]; + var buffer = 0; + var bitsLeft = 0; + var index = 0; + + foreach (var b in data) + { + buffer = (buffer << 8) | b; + bitsLeft += 8; + + while (bitsLeft >= 5) + { + bitsLeft -= 5; + result[index++] = Base32Chars[(buffer >> bitsLeft) & 0x1F]; + } + } + + if (bitsLeft > 0) + { + buffer <<= (5 - bitsLeft); + result[index] = Base32Chars[buffer & 0x1F]; + } + + return new string(result); + } + + internal static byte[] Base32Decode(string input) + { + input = input.TrimEnd('=').ToUpperInvariant(); + var output = new byte[input.Length * 5 / 8]; + var buffer = 0; + var bitsLeft = 0; + var index = 0; + + foreach (var c in input) + { + var value = c switch + { + >= 'A' and <= 'Z' => c - 'A', + >= '2' and <= '7' => c - '2' + 26, + _ => throw new ArgumentException($"Invalid base32 character: {c}") + }; + + buffer = (buffer << 5) | value; + bitsLeft += 5; + + if (bitsLeft >= 8) + { + bitsLeft -= 8; + output[index++] = (byte)(buffer >> bitsLeft); + } + } + + return output[..index]; + } + + private static string[] GenerateRecoveryCodes(int count) + { + var codes = new string[count]; + for (var i = 0; i < count; i++) + { + // Generate an 8-character alphanumeric recovery code in two groups: XXXX-XXXX + var bytes = RandomNumberGenerator.GetBytes(5); + var hex = Convert.ToHexString(bytes).ToUpperInvariant()[..8]; + codes[i] = $"{hex[..4]}-{hex[4..8]}"; + } + return codes; + } +} diff --git a/backend/src/Taskdeck.Application/Services/OidcProviderSettings.cs b/backend/src/Taskdeck.Application/Services/OidcProviderSettings.cs new file mode 100644 index 000000000..642e8ab85 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/OidcProviderSettings.cs @@ -0,0 +1,39 @@ +namespace Taskdeck.Application.Services; + +/// +/// Configuration for a single OIDC provider (e.g., Microsoft Entra ID, Google). +/// OIDC is only active when Authority, ClientId, and ClientSecret are all configured. +/// +public class OidcProviderConfig +{ + public string Name { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string Authority { get; set; } = string.Empty; + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; + public string[] Scopes { get; set; } = ["openid", "profile", "email"]; + public string CallbackPath { get; set; } = string.Empty; + + /// + /// Returns true when this OIDC provider is fully configured and should be active. + /// + public bool IsConfigured => + !string.IsNullOrWhiteSpace(Name) + && !string.IsNullOrWhiteSpace(Authority) + && !string.IsNullOrWhiteSpace(ClientId) + && !string.IsNullOrWhiteSpace(ClientSecret); +} + +/// +/// Top-level OIDC configuration holding all configured providers. +/// +public class OidcSettings +{ + public List Providers { get; set; } = []; + + /// + /// Returns all providers that are fully configured. + /// + public IReadOnlyList ConfiguredProviders => + Providers.Where(p => p.IsConfigured).ToList().AsReadOnly(); +} diff --git a/backend/src/Taskdeck.Domain/Entities/MfaCredential.cs b/backend/src/Taskdeck.Domain/Entities/MfaCredential.cs new file mode 100644 index 000000000..4ba0fff2a --- /dev/null +++ b/backend/src/Taskdeck.Domain/Entities/MfaCredential.cs @@ -0,0 +1,90 @@ +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Domain.Entities; + +/// +/// Stores a TOTP-based MFA credential for a user. +/// Each user may have at most one active TOTP credential. +/// WARNING: The shared secret is currently stored as plaintext in the database. +/// A future enhancement should add an EF Core value converter with Data Protection +/// or AES-GCM encryption before this feature is used in production deployments. +/// +public class MfaCredential : Entity +{ + private string _secret = string.Empty; + + public Guid UserId { get; private set; } + + /// + /// Base32-encoded TOTP shared secret. + /// + public string Secret + { + get => _secret; + private set + { + if (string.IsNullOrWhiteSpace(value)) + throw new DomainException(ErrorCodes.ValidationError, "MFA secret cannot be empty"); + + if (value.Length > 512) + throw new DomainException(ErrorCodes.ValidationError, "MFA secret cannot exceed 512 characters"); + + _secret = value; + } + } + + /// + /// Whether this credential has been confirmed by the user entering a valid TOTP code. + /// Unconfirmed credentials should not be used for authentication gates. + /// + public bool IsConfirmed { get; private set; } + + /// + /// Comma-separated recovery codes (hashed). Generated at setup time. + /// + public string? RecoveryCodes { get; private set; } + + private MfaCredential() : base() { } + + public MfaCredential(Guid userId, string secret) + : base() + { + if (userId == Guid.Empty) + throw new DomainException(ErrorCodes.ValidationError, "User ID cannot be empty"); + + UserId = userId; + Secret = secret; + IsConfirmed = false; + } + + /// + /// Confirms the credential after the user successfully validates a TOTP code. + /// + public void Confirm() + { + IsConfirmed = true; + Touch(); + } + + /// + /// Sets the hashed recovery codes for this credential. + /// Pass null or empty to clear all recovery codes (e.g., when exhausted). + /// + public void SetRecoveryCodes(string? hashedRecoveryCodes) + { + RecoveryCodes = string.IsNullOrWhiteSpace(hashedRecoveryCodes) ? null : hashedRecoveryCodes; + Touch(); + } + + /// + /// Revokes this MFA credential by marking as unconfirmed. + /// Note: The secret is intentionally preserved for audit trail purposes; + /// full deletion should use repository deletion instead. + /// + public void Revoke() + { + IsConfirmed = false; + Touch(); + } +} diff --git a/backend/src/Taskdeck.Domain/Entities/User.cs b/backend/src/Taskdeck.Domain/Entities/User.cs index 56a8f8e1c..7b910343b 100644 --- a/backend/src/Taskdeck.Domain/Entities/User.cs +++ b/backend/src/Taskdeck.Domain/Entities/User.cs @@ -70,6 +70,11 @@ private set /// public DateTimeOffset? TokenInvalidatedAt { get; private set; } + /// + /// Whether this user has MFA enabled (confirmed TOTP credential exists). + /// + public bool MfaEnabled { get; private set; } + private User() : base() { } public User(string username, string email, string passwordHash, UserRole defaultRole = UserRole.Editor) @@ -126,6 +131,18 @@ public void Activate() Touch(); } + public void EnableMfa() + { + MfaEnabled = true; + Touch(); + } + + public void DisableMfa() + { + MfaEnabled = false; + Touch(); + } + /// /// Marks all existing JWT tokens as invalid by recording the current UTC time /// truncated to whole-second precision. JWT iat claims are Unix diff --git a/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs b/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs index 8381ba0c6..6e10e43f1 100644 --- a/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs +++ b/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs @@ -45,6 +45,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/src/Taskdeck.Infrastructure/Migrations/20260409120000_AddMfaCredentials.Designer.cs b/backend/src/Taskdeck.Infrastructure/Migrations/20260409120000_AddMfaCredentials.Designer.cs new file mode 100644 index 000000000..ee0a8638b --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Migrations/20260409120000_AddMfaCredentials.Designer.cs @@ -0,0 +1,1915 @@ +// +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("20260409120000_AddMfaCredentials")] + partial class AddMfaCredentials + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.14"); + + 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.MfaCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RecoveryCodes") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Secret") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("MfaCredentials", (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.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("MfaEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + 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.MfaCredential", 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.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/20260409120000_AddMfaCredentials.cs b/backend/src/Taskdeck.Infrastructure/Migrations/20260409120000_AddMfaCredentials.cs new file mode 100644 index 000000000..e7b92d6d8 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Migrations/20260409120000_AddMfaCredentials.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Taskdeck.Infrastructure.Migrations +{ + /// + public partial class AddMfaCredentials : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MfaEnabled", + table: "Users", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateTable( + name: "MfaCredentials", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + Secret = table.Column(type: "TEXT", maxLength: 512, nullable: false), + IsConfirmed = table.Column(type: "INTEGER", nullable: false), + RecoveryCodes = table.Column(type: "TEXT", maxLength: 4096, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MfaCredentials", x => x.Id); + table.ForeignKey( + name: "FK_MfaCredentials_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_MfaCredentials_UserId", + table: "MfaCredentials", + column: "UserId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MfaCredentials"); + + migrationBuilder.DropColumn( + name: "MfaEnabled", + table: "Users"); + } + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs b/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs index fde5581ed..8e404a9eb 100644 --- a/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs +++ b/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs @@ -1060,6 +1060,41 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("KnowledgeDocuments", (string)null); }); + modelBuilder.Entity("Taskdeck.Domain.Entities.MfaCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RecoveryCodes") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Secret") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("MfaCredentials", (string)null); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.Label", b => { b.Property("Id") @@ -1494,6 +1529,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsActive") .HasColumnType("INTEGER"); + b.Property("MfaEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + b.Property("PasswordHash") .IsRequired() .HasMaxLength(255) @@ -1761,6 +1801,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("Taskdeck.Domain.Entities.MfaCredential", 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) diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/MfaCredentialConfiguration.cs b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/MfaCredentialConfiguration.cs new file mode 100644 index 000000000..0de3a1a37 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/MfaCredentialConfiguration.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Infrastructure.Persistence.Configurations; + +public class MfaCredentialConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("MfaCredentials"); + + builder.HasKey(e => e.Id); + + builder.Property(e => e.UserId) + .IsRequired(); + + builder.Property(e => e.Secret) + .IsRequired() + .HasMaxLength(512); + + builder.Property(e => e.IsConfirmed) + .IsRequired(); + + builder.Property(e => e.RecoveryCodes) + .HasMaxLength(4096); + + builder.Property(e => e.CreatedAt) + .IsRequired(); + + builder.Property(e => e.UpdatedAt) + .IsRequired(); + + // Foreign key to Users with cascading delete + builder.HasOne() + .WithMany() + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + + // One credential per user + builder.HasIndex(e => e.UserId) + .IsUnique(); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/UserConfiguration.cs b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/UserConfiguration.cs index e8703af1e..b515a5802 100644 --- a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/UserConfiguration.cs +++ b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/UserConfiguration.cs @@ -34,6 +34,10 @@ public void Configure(EntityTypeBuilder builder) builder.Property(u => u.TokenInvalidatedAt) .IsRequired(false); + builder.Property(u => u.MfaEnabled) + .IsRequired() + .HasDefaultValue(false); + builder.Property(u => u.CreatedAt) .IsRequired(); diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs b/backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs index 9e9bac9a0..3391cb99f 100644 --- a/backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs +++ b/backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs @@ -41,6 +41,7 @@ public TaskdeckDbContext(DbContextOptions options) : base(opt public DbSet ExternalLogins => Set(); public DbSet OAuthAuthCodes => Set(); public DbSet ApiKeys => Set(); + public DbSet MfaCredentials => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/MfaCredentialRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/MfaCredentialRepository.cs new file mode 100644 index 000000000..27800348a --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Repositories/MfaCredentialRepository.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Entities; +using Taskdeck.Infrastructure.Persistence; + +namespace Taskdeck.Infrastructure.Repositories; + +public class MfaCredentialRepository : Repository, IMfaCredentialRepository +{ + public MfaCredentialRepository(TaskdeckDbContext context) : base(context) + { + } + + public async Task GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) + { + return await _context.MfaCredentials + .FirstOrDefaultAsync(e => e.UserId == userId, cancellationToken); + } + + public async Task DeleteByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) + { + var existing = await _context.MfaCredentials + .Where(e => e.UserId == userId) + .ToListAsync(cancellationToken); + + if (existing.Count > 0) + { + _context.MfaCredentials.RemoveRange(existing); + } + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs b/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs index a88642a2b..c6d346299 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs @@ -40,7 +40,8 @@ public UnitOfWork( IKnowledgeChunkRepository knowledgeChunks, IExternalLoginRepository externalLogins, IOAuthAuthCodeRepository oauthAuthCodes, - IApiKeyRepository apiKeys) + IApiKeyRepository apiKeys, + IMfaCredentialRepository mfaCredentials) { _context = context; Boards = boards; @@ -70,6 +71,7 @@ public UnitOfWork( ExternalLogins = externalLogins; OAuthAuthCodes = oauthAuthCodes; ApiKeys = apiKeys; + MfaCredentials = mfaCredentials; } public IBoardRepository Boards { get; } @@ -99,6 +101,7 @@ public UnitOfWork( public IExternalLoginRepository ExternalLogins { get; } public IOAuthAuthCodeRepository OAuthAuthCodes { get; } public IApiKeyRepository ApiKeys { get; } + public IMfaCredentialRepository MfaCredentials { get; } public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { diff --git a/backend/tests/Taskdeck.Api.Tests/ActiveUserValidationMiddlewareTests.cs b/backend/tests/Taskdeck.Api.Tests/ActiveUserValidationMiddlewareTests.cs index 1bfeafc2e..765ade45d 100644 --- a/backend/tests/Taskdeck.Api.Tests/ActiveUserValidationMiddlewareTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/ActiveUserValidationMiddlewareTests.cs @@ -302,6 +302,7 @@ public StubUnitOfWork(User? userToReturn) public IExternalLoginRepository ExternalLogins => throw new NotImplementedException(); public IOAuthAuthCodeRepository OAuthAuthCodes => throw new NotImplementedException(); public IApiKeyRepository ApiKeys => throw new NotImplementedException(); + public IMfaCredentialRepository MfaCredentials => throw new NotImplementedException(); public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(0); public Task BeginTransactionAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; diff --git a/backend/tests/Taskdeck.Api.Tests/AuthControllerEdgeCaseTests.cs b/backend/tests/Taskdeck.Api.Tests/AuthControllerEdgeCaseTests.cs index 91fea7273..23a9ad0b9 100644 --- a/backend/tests/Taskdeck.Api.Tests/AuthControllerEdgeCaseTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/AuthControllerEdgeCaseTests.cs @@ -179,7 +179,7 @@ public void GitHubLogin_UnauthenticatedCaller_StartsNormalLoginFlow() authCodeRepoMock.Setup(r => r.GetByCodeAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((OAuthAuthCode?)null); uow.Setup(u => u.OAuthAuthCodes).Returns(authCodeRepoMock.Object); - var controller = new AuthController(authService.Object, CreateGitHubSettings(true), userContext.Object, uow.Object); + var controller = new AuthController(authService.Object, CreateGitHubSettings(true), new OidcSettings(), CreateMockMfaService(), userContext.Object, uow.Object); var urlHelper = new Mock(); urlHelper.Setup(u => u.IsLocalUrl(It.IsAny())).Returns(true); @@ -207,7 +207,7 @@ public void GitHubLogin_AuthenticatedCaller_StartsLinkFlowFromServerState() authCodeRepoMock.Setup(r => r.GetByCodeAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((OAuthAuthCode?)null); uow.Setup(u => u.OAuthAuthCodes).Returns(authCodeRepoMock.Object); - var controller = new AuthController(authService.Object, CreateGitHubSettings(true), userContext.Object, uow.Object); + var controller = new AuthController(authService.Object, CreateGitHubSettings(true), new OidcSettings(), CreateMockMfaService(), userContext.Object, uow.Object); var urlHelper = new Mock(); urlHelper.Setup(u => u.IsLocalUrl(It.IsAny())).Returns(true); @@ -340,7 +340,7 @@ public async Task TokenValidationMiddleware_ShouldPassThrough_WhenClaimsHaveNoUs public async Task Login_ShouldReturn401_WhenBodyIsNull() { var (uow, authService) = CreateMockAuthServiceWithUow(); - var controller = new AuthController(authService.Object, CreateGitHubSettings(false), CreateMockUserContext().Object, uow.Object); + var controller = new AuthController(authService.Object, CreateGitHubSettings(false), new OidcSettings(), CreateMockMfaService(), CreateMockUserContext().Object, uow.Object); var result = await controller.Login(null); @@ -353,7 +353,7 @@ public async Task Login_ShouldReturn401_WhenBodyIsNull() public async Task Login_ShouldReturn401_WhenFieldsEmpty() { var (uow, authService) = CreateMockAuthServiceWithUow(); - var controller = new AuthController(authService.Object, CreateGitHubSettings(false), CreateMockUserContext().Object, uow.Object); + var controller = new AuthController(authService.Object, CreateGitHubSettings(false), new OidcSettings(), CreateMockMfaService(), CreateMockUserContext().Object, uow.Object); var result = await controller.Login(new LoginDto("", "")); @@ -377,7 +377,7 @@ private static (AuthController Controller, Mock UnitOfWork) CreateA .ReturnsAsync((OAuthAuthCode?)null); unitOfWorkMock.Setup(u => u.OAuthAuthCodes).Returns(authCodeRepoMock.Object); - var controller = new AuthController(authServiceMock.Object, gitHubSettings, CreateMockUserContext().Object, unitOfWorkMock.Object); + var controller = new AuthController(authServiceMock.Object, gitHubSettings, new OidcSettings(), CreateMockMfaService(), CreateMockUserContext().Object, unitOfWorkMock.Object); return (controller, unitOfWorkMock); } @@ -400,6 +400,15 @@ private static Mock CreateMockUserContext(bool authenticated = tru return mock; } + private static MfaService CreateMockMfaService() + { + var unitOfWorkMock = new Mock(); + unitOfWorkMock.Setup(u => u.Users).Returns(new Mock().Object); + unitOfWorkMock.Setup(u => u.MfaCredentials).Returns(new Mock().Object); + var policySettings = new MfaPolicySettings(); + return new MfaService(unitOfWorkMock.Object, policySettings); + } + private static GitHubOAuthSettings CreateGitHubSettings(bool configured) { return configured diff --git a/backend/tests/Taskdeck.Api.Tests/AuthenticationRegistrationTests.cs b/backend/tests/Taskdeck.Api.Tests/AuthenticationRegistrationTests.cs new file mode 100644 index 000000000..1afff91f8 --- /dev/null +++ b/backend/tests/Taskdeck.Api.Tests/AuthenticationRegistrationTests.cs @@ -0,0 +1,69 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authentication.OAuth; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Taskdeck.Api.Extensions; +using Taskdeck.Application.Services; +using Xunit; + +namespace Taskdeck.Api.Tests; + +public class AuthenticationRegistrationTests +{ + [Fact] + public async Task AddTaskdeckAuthentication_ConfiguresExternalSignInScheme_ForRemoteProviders() + { + var services = new ServiceCollection(); + var jwtSettings = new JwtSettings + { + SecretKey = "TaskdeckTestsOnlySecretKeyMustBeLongEnough123!", + Issuer = "TaskdeckTests", + Audience = "TaskdeckUsers", + ExpirationMinutes = 60 + }; + var gitHubSettings = new GitHubOAuthSettings + { + ClientId = "github-client", + ClientSecret = "github-secret" + }; + var oidcSettings = new OidcSettings + { + Providers = + [ + new OidcProviderConfig + { + Name = "entra", + DisplayName = "Microsoft Entra ID", + Authority = "https://login.microsoftonline.com/tenant/v2.0", + ClientId = "oidc-client", + ClientSecret = "oidc-secret" + } + ] + }; + + services.AddLogging(); + services.AddOptions(); + services.AddTaskdeckAuthentication(jwtSettings, gitHubSettings, oidcSettings); + + await using var serviceProvider = services.BuildServiceProvider(); + + var authenticationOptions = serviceProvider.GetRequiredService>().Value; + authenticationOptions.DefaultAuthenticateScheme.Should().Be(JwtBearerDefaults.AuthenticationScheme); + authenticationOptions.DefaultChallengeScheme.Should().Be(JwtBearerDefaults.AuthenticationScheme); + authenticationOptions.DefaultSignInScheme.Should().Be(AuthenticationRegistration.ExternalAuthenticationScheme); + + var schemeProvider = serviceProvider.GetRequiredService(); + (await schemeProvider.GetSchemeAsync(AuthenticationRegistration.ExternalAuthenticationScheme)).Should().NotBeNull(); + (await schemeProvider.GetSchemeAsync("GitHub")).Should().NotBeNull(); + (await schemeProvider.GetSchemeAsync("Oidc_entra")).Should().NotBeNull(); + + var gitHubOptions = serviceProvider.GetRequiredService>().Get("GitHub"); + gitHubOptions.SignInScheme.Should().Be(AuthenticationRegistration.ExternalAuthenticationScheme); + + var oidcOptions = serviceProvider.GetRequiredService>().Get("Oidc_entra"); + oidcOptions.SignInScheme.Should().Be(AuthenticationRegistration.ExternalAuthenticationScheme); + } +} diff --git a/backend/tests/Taskdeck.Api.Tests/LlmQueueToProposalWorkerTests.cs b/backend/tests/Taskdeck.Api.Tests/LlmQueueToProposalWorkerTests.cs index b04920b3f..8e971a34c 100644 --- a/backend/tests/Taskdeck.Api.Tests/LlmQueueToProposalWorkerTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/LlmQueueToProposalWorkerTests.cs @@ -845,6 +845,7 @@ public FakeUnitOfWork(ILlmQueueRepository llmQueue) public IExternalLoginRepository ExternalLogins => null!; public IOAuthAuthCodeRepository OAuthAuthCodes => null!; public IApiKeyRepository ApiKeys => null!; + public IMfaCredentialRepository MfaCredentials => null!; public Task SaveChangesAsync(CancellationToken cancellationToken = default) { diff --git a/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerReliabilityTests.cs b/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerReliabilityTests.cs index 7eb2d4a7b..61fdcd3f8 100644 --- a/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerReliabilityTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerReliabilityTests.cs @@ -644,6 +644,7 @@ public FakeUnitOfWork(IOutboundWebhookDeliveryRepository deliveries) public IExternalLoginRepository ExternalLogins => null!; public IOAuthAuthCodeRepository OAuthAuthCodes => null!; public IApiKeyRepository ApiKeys => null!; + public IMfaCredentialRepository MfaCredentials => null!; public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(0); diff --git a/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerTests.cs b/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerTests.cs index 000b8120e..d5b76f07b 100644 --- a/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerTests.cs @@ -550,6 +550,7 @@ public FakeUnitOfWork(IOutboundWebhookDeliveryRepository outboundWebhookDelivery public IExternalLoginRepository ExternalLogins => null!; public IOAuthAuthCodeRepository OAuthAuthCodes => null!; public IApiKeyRepository ApiKeys => null!; + public IMfaCredentialRepository MfaCredentials => 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 f45c2bca1..80837f4d0 100644 --- a/backend/tests/Taskdeck.Api.Tests/OutboundWebhookHmacDeliveryTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/OutboundWebhookHmacDeliveryTests.cs @@ -537,6 +537,7 @@ public StubUnitOfWork(IOutboundWebhookDeliveryRepository deliveries) public IExternalLoginRepository ExternalLogins => null!; public IOAuthAuthCodeRepository OAuthAuthCodes => null!; public IApiKeyRepository ApiKeys => null!; + public IMfaCredentialRepository MfaCredentials => null!; public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(0); diff --git a/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerEdgeCaseTests.cs b/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerEdgeCaseTests.cs index 77f4f7264..3f372f13f 100644 --- a/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerEdgeCaseTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerEdgeCaseTests.cs @@ -343,6 +343,7 @@ public FakeUnitOfWork(IAutomationProposalRepository repo) public IExternalLoginRepository ExternalLogins => null!; public IOAuthAuthCodeRepository OAuthAuthCodes => null!; public IApiKeyRepository ApiKeys => null!; + public IMfaCredentialRepository MfaCredentials => 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 6b4226ae3..1922bd4dc 100644 --- a/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerTests.cs @@ -221,6 +221,7 @@ public FakeUnitOfWork(IAutomationProposalRepository automationProposalRepository public IExternalLoginRepository ExternalLogins => null!; public IOAuthAuthCodeRepository OAuthAuthCodes => null!; public IApiKeyRepository ApiKeys => null!; + public IMfaCredentialRepository MfaCredentials => 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 8d4f99299..9facfa08c 100644 --- a/backend/tests/Taskdeck.Api.Tests/WorkerResilienceTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/WorkerResilienceTests.cs @@ -597,6 +597,7 @@ private sealed class FakeUnitOfWorkWithLlmQueue : IUnitOfWork public IExternalLoginRepository ExternalLogins => null!; public IOAuthAuthCodeRepository OAuthAuthCodes => null!; public IApiKeyRepository ApiKeys => null!; + public IMfaCredentialRepository MfaCredentials => null!; public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(0); public Task BeginTransactionAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; public Task CommitTransactionAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; @@ -633,6 +634,7 @@ private sealed class FakeUnitOfWorkWithProposals : IUnitOfWork public IExternalLoginRepository ExternalLogins => null!; public IOAuthAuthCodeRepository OAuthAuthCodes => null!; public IApiKeyRepository ApiKeys => null!; + public IMfaCredentialRepository MfaCredentials => null!; public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(0); public Task BeginTransactionAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; public Task CommitTransactionAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; diff --git a/backend/tests/Taskdeck.Application.Tests/Services/MfaServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/MfaServiceTests.cs new file mode 100644 index 000000000..06ece5608 --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/MfaServiceTests.cs @@ -0,0 +1,315 @@ +using FluentAssertions; +using Moq; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class MfaServiceTests +{ + private readonly Mock _unitOfWorkMock; + private readonly Mock _userRepoMock; + private readonly Mock _mfaRepoMock; + private readonly MfaPolicySettings _policySettings; + + public MfaServiceTests() + { + _unitOfWorkMock = new Mock(); + _userRepoMock = new Mock(); + _mfaRepoMock = new Mock(); + + _unitOfWorkMock.Setup(u => u.Users).Returns(_userRepoMock.Object); + _unitOfWorkMock.Setup(u => u.MfaCredentials).Returns(_mfaRepoMock.Object); + + _policySettings = new MfaPolicySettings + { + EnableMfaSetup = true, + RequireMfaForSensitiveActions = true, + TotpTimeStepSeconds = 30, + RecoveryCodeCount = 8, + TotpToleranceSteps = 1 + }; + } + + private MfaService CreateService() => new(_unitOfWorkMock.Object, _policySettings); + + // ── Setup Tests ───────────────────────────────────────────────── + + [Fact] + public async Task Setup_ShouldReturnForbidden_WhenMfaSetupDisabled() + { + _policySettings.EnableMfaSetup = false; + var service = CreateService(); + var user = CreateUser(); + _userRepoMock.Setup(r => r.GetByIdAsync(user.Id, default)).ReturnsAsync(user); + + var result = await service.SetupAsync(user.Id); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Forbidden); + } + + [Fact] + public async Task Setup_ShouldReturnNotFound_WhenUserDoesNotExist() + { + var service = CreateService(); + + var result = await service.SetupAsync(Guid.NewGuid()); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + } + + [Fact] + public async Task Setup_ShouldReturnConflict_WhenMfaAlreadyEnabled() + { + var service = CreateService(); + var user = CreateUser(); + user.EnableMfa(); + _userRepoMock.Setup(r => r.GetByIdAsync(user.Id, default)).ReturnsAsync(user); + + var result = await service.SetupAsync(user.Id); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Conflict); + } + + [Fact] + public async Task Setup_ShouldReturnSecretAndRecoveryCodes() + { + var service = CreateService(); + var user = CreateUser(); + _userRepoMock.Setup(r => r.GetByIdAsync(user.Id, default)).ReturnsAsync(user); + + var result = await service.SetupAsync(user.Id); + + result.IsSuccess.Should().BeTrue(); + result.Value.SharedSecret.Should().NotBeNullOrWhiteSpace(); + result.Value.QrCodeUri.Should().Contain("otpauth://totp/"); + result.Value.QrCodeUri.Should().Contain(user.Username); + result.Value.RecoveryCodes.Should().HaveCount(8); + } + + [Fact] + public async Task Setup_ShouldDeleteExistingUnconfirmedCredential() + { + var service = CreateService(); + var user = CreateUser(); + _userRepoMock.Setup(r => r.GetByIdAsync(user.Id, default)).ReturnsAsync(user); + + await service.SetupAsync(user.Id); + + _mfaRepoMock.Verify(r => r.DeleteByUserIdAsync(user.Id, default), Times.Once); + } + + // ── Confirm Tests ─────────────────────────────────────────────── + + [Fact] + public async Task Confirm_ShouldReturnValidationError_WhenCodeEmpty() + { + var service = CreateService(); + + var result = await service.ConfirmSetupAsync(Guid.NewGuid(), ""); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task Confirm_ShouldReturnNotFound_WhenNoSetupInProgress() + { + var service = CreateService(); + _mfaRepoMock.Setup(r => r.GetByUserIdAsync(It.IsAny(), default)).ReturnsAsync((MfaCredential?)null); + + var result = await service.ConfirmSetupAsync(Guid.NewGuid(), "123456"); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + } + + [Fact] + public async Task Confirm_ShouldReturnConflict_WhenAlreadyConfirmed() + { + var service = CreateService(); + var user = CreateUser(); + var credential = new MfaCredential(user.Id, MfaService.Base32Encode(new byte[20])); + credential.Confirm(); + _mfaRepoMock.Setup(r => r.GetByUserIdAsync(user.Id, default)).ReturnsAsync(credential); + + var result = await service.ConfirmSetupAsync(user.Id, "123456"); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Conflict); + } + + // ── Disable Tests ─────────────────────────────────────────────── + + [Fact] + public async Task Disable_ShouldReturnValidationError_WhenCodeEmpty() + { + var service = CreateService(); + + var result = await service.DisableAsync(Guid.NewGuid(), ""); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task Disable_ShouldReturnValidationError_WhenMfaNotEnabled() + { + var service = CreateService(); + var user = CreateUser(); + _userRepoMock.Setup(r => r.GetByIdAsync(user.Id, default)).ReturnsAsync(user); + + var result = await service.DisableAsync(user.Id, "123456"); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + // ── Verify Tests ──────────────────────────────────────────────── + + [Fact] + public async Task Verify_ShouldReturnValidationError_WhenCodeEmpty() + { + var service = CreateService(); + + var result = await service.VerifyCodeAsync(Guid.NewGuid(), ""); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task Verify_ShouldReturnValidationError_WhenMfaNotEnabled() + { + var service = CreateService(); + var user = CreateUser(); + _userRepoMock.Setup(r => r.GetByIdAsync(user.Id, default)).ReturnsAsync(user); + + var result = await service.VerifyCodeAsync(user.Id, "123456"); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + // ── TOTP Validation Tests ─────────────────────────────────────── + + [Fact] + public void ValidateTotp_ShouldRejectCodeWithWrongLength() + { + var service = CreateService(); + var secret = MfaService.Base32Encode(new byte[20]); + + service.ValidateTotp(secret, "12345").Should().BeFalse(); + service.ValidateTotp(secret, "1234567").Should().BeFalse(); + } + + [Fact] + public void ValidateTotp_ShouldRejectEmptyCode() + { + var service = CreateService(); + var secret = MfaService.Base32Encode(new byte[20]); + + service.ValidateTotp(secret, "").Should().BeFalse(); + service.ValidateTotp(secret, null!).Should().BeFalse(); + } + + // ── Base32 Encoding Tests ─────────────────────────────────────── + + [Fact] + public void Base32_ShouldRoundTrip() + { + var original = new byte[] { 0x48, 0x65, 0x6C, 0x6C, 0x6F }; + var encoded = MfaService.Base32Encode(original); + var decoded = MfaService.Base32Decode(encoded); + + decoded.Should().BeEquivalentTo(original); + } + + [Fact] + public void Base32Encode_ShouldProduceValidCharacters() + { + var data = new byte[20]; + new Random(42).NextBytes(data); + var encoded = MfaService.Base32Encode(data); + + encoded.Should().MatchRegex("^[A-Z2-7]+$"); + } + + // ── Status Tests ──────────────────────────────────────────────── + + [Fact] + public async Task GetStatus_ShouldReturnCorrectState() + { + var service = CreateService(); + var user = CreateUser(); + _userRepoMock.Setup(r => r.GetByIdAsync(user.Id, default)).ReturnsAsync(user); + + var result = await service.GetStatusAsync(user.Id); + + result.IsSuccess.Should().BeTrue(); + result.Value.IsEnabled.Should().BeFalse(); + result.Value.IsSetupAvailable.Should().BeTrue(); + } + + [Fact] + public async Task GetStatus_ShouldReturnNotFound_WhenUserMissing() + { + var service = CreateService(); + + var result = await service.GetStatusAsync(Guid.NewGuid()); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + } + + // ── Policy Tests ──────────────────────────────────────────────── + + [Fact] + public async Task IsMfaRequired_ShouldReturnFalse_WhenPolicyDisabled() + { + _policySettings.RequireMfaForSensitiveActions = false; + var service = CreateService(); + + var required = await service.IsMfaRequiredForSensitiveActionAsync(Guid.NewGuid()); + + required.Should().BeFalse(); + } + + [Fact] + public async Task IsMfaRequired_ShouldReturnFalse_WhenUserHasNoMfa() + { + var service = CreateService(); + var user = CreateUser(); + _userRepoMock.Setup(r => r.GetByIdAsync(user.Id, default)).ReturnsAsync(user); + + var required = await service.IsMfaRequiredForSensitiveActionAsync(user.Id); + + required.Should().BeFalse(); + } + + [Fact] + public async Task IsMfaRequired_ShouldReturnTrue_WhenPolicyEnabledAndUserHasMfa() + { + var service = CreateService(); + var user = CreateUser(); + user.EnableMfa(); + _userRepoMock.Setup(r => r.GetByIdAsync(user.Id, default)).ReturnsAsync(user); + + var required = await service.IsMfaRequiredForSensitiveActionAsync(user.Id); + + required.Should().BeTrue(); + } + + // ── Helpers ────────────────────────────────────────────────────── + + private static User CreateUser() + { + return new User("testuser", "test@example.com", BCrypt.Net.BCrypt.HashPassword("password123")); + } +} diff --git a/backend/tests/Taskdeck.Application.Tests/Services/OidcSecurityTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/OidcSecurityTests.cs new file mode 100644 index 000000000..316a72163 --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/OidcSecurityTests.cs @@ -0,0 +1,240 @@ +using FluentAssertions; +using Moq; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +/// +/// Security-focused tests for OIDC external login flows. +/// Validates claim mapping, email collision protection, and failure modes. +/// +public class OidcSecurityTests +{ + private static readonly JwtSettings DefaultJwtSettings = new() + { + SecretKey = "ThisIsATestSecretKeyThatIsAtLeast32CharactersLong!", + Issuer = "TestIssuer", + Audience = "TestAudience", + ExpirationMinutes = 60 + }; + + private readonly Mock _unitOfWorkMock; + private readonly Mock _userRepoMock; + private readonly Mock _externalLoginRepoMock; + + public OidcSecurityTests() + { + _unitOfWorkMock = new Mock(); + _userRepoMock = new Mock(); + _externalLoginRepoMock = new Mock(); + + _unitOfWorkMock.Setup(u => u.Users).Returns(_userRepoMock.Object); + _unitOfWorkMock.Setup(u => u.ExternalLogins).Returns(_externalLoginRepoMock.Object); + } + + private AuthenticationService CreateService() => new(_unitOfWorkMock.Object, DefaultJwtSettings); + + // ── Provider Validation ───────────────────────────────────────── + + [Fact] + public async Task ExternalLogin_ShouldReject_EmptyProvider() + { + var service = CreateService(); + var dto = new ExternalLoginDto("", "user123", "testuser", "test@example.com"); + + var result = await service.ExternalLoginAsync(dto); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ExternalLogin_ShouldReject_EmptyProviderUserId() + { + var service = CreateService(); + var dto = new ExternalLoginDto("oidc_entra", "", "testuser", "test@example.com"); + + var result = await service.ExternalLoginAsync(dto); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + // ── Email Collision Protection ────────────────────────────────── + + [Fact] + public async Task ExternalLogin_ShouldNotAutoLink_WhenEmailCollides() + { + var service = CreateService(); + var existingUser = new User("existing", "shared@example.com", BCrypt.Net.BCrypt.HashPassword("password")); + _userRepoMock.Setup(r => r.GetByEmailAsync("shared@example.com", default)).ReturnsAsync(existingUser); + _externalLoginRepoMock.Setup(r => r.GetByProviderAsync("oidc_entra", "attacker123", default)).ReturnsAsync((ExternalLogin?)null); + + var dto = new ExternalLoginDto("oidc_entra", "attacker123", "attacker", "shared@example.com"); + + var result = await service.ExternalLoginAsync(dto); + + // Should succeed but create a NEW user, not link to the existing one + result.IsSuccess.Should().BeTrue(); + result.Value.User.Id.Should().NotBe(existingUser.Id); + } + + [Fact] + public async Task ExternalLogin_ShouldGenerateUniqueEmail_WhenCollision() + { + var service = CreateService(); + var existingUser = new User("existing", "shared@example.com", BCrypt.Net.BCrypt.HashPassword("password")); + _userRepoMock.Setup(r => r.GetByEmailAsync("shared@example.com", default)).ReturnsAsync(existingUser); + _externalLoginRepoMock.Setup(r => r.GetByProviderAsync("oidc_entra", "user456", default)).ReturnsAsync((ExternalLogin?)null); + + var dto = new ExternalLoginDto("oidc_entra", "user456", "newuser", "shared@example.com"); + + var result = await service.ExternalLoginAsync(dto); + + result.IsSuccess.Should().BeTrue(); + // The email used should NOT be the colliding one + result.Value.User.Email.Should().NotBe("shared@example.com"); + result.Value.User.Email.Should().Contain("external.taskdeck.local"); + } + + // ── Existing Provider Link ────────────────────────────────────── + + [Fact] + public async Task ExternalLogin_ShouldReturnExistingUser_WhenProviderLinkExists() + { + var service = CreateService(); + var existingUser = new User("linked-user", "linked@example.com", BCrypt.Net.BCrypt.HashPassword("password")); + var externalLogin = new ExternalLogin(existingUser.Id, "oidc_google", "google123", "Test User"); + + _externalLoginRepoMock.Setup(r => r.GetByProviderAsync("oidc_google", "google123", default)).ReturnsAsync(externalLogin); + _userRepoMock.Setup(r => r.GetByIdAsync(existingUser.Id, default)).ReturnsAsync(existingUser); + + var dto = new ExternalLoginDto("oidc_google", "google123", "linked-user", "linked@example.com"); + + var result = await service.ExternalLoginAsync(dto); + + result.IsSuccess.Should().BeTrue(); + result.Value.User.Id.Should().Be(existingUser.Id); + } + + [Fact] + public async Task ExternalLogin_ShouldReject_InactiveLinkedUser() + { + var service = CreateService(); + var inactiveUser = new User("inactive-user", "inactive@example.com", BCrypt.Net.BCrypt.HashPassword("password")); + inactiveUser.Deactivate(); + var externalLogin = new ExternalLogin(inactiveUser.Id, "oidc_google", "google789"); + + _externalLoginRepoMock.Setup(r => r.GetByProviderAsync("oidc_google", "google789", default)).ReturnsAsync(externalLogin); + _userRepoMock.Setup(r => r.GetByIdAsync(inactiveUser.Id, default)).ReturnsAsync(inactiveUser); + + var dto = new ExternalLoginDto("oidc_google", "google789", "inactive-user", "inactive@example.com"); + + var result = await service.ExternalLoginAsync(dto); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Forbidden); + } + + // ── Username Collision Handling ────────────────────────────────── + + [Fact] + public async Task ExternalLogin_ShouldDeduplicateUsername_WhenCollision() + { + var service = CreateService(); + var existingUser = new User("oidcuser", "other@example.com", BCrypt.Net.BCrypt.HashPassword("password")); + _userRepoMock.Setup(r => r.GetByUsernameAsync("oidcuser", default)).ReturnsAsync(existingUser); + _userRepoMock.Setup(r => r.GetByUsernameAsync("oidcuser1", default)).ReturnsAsync((User?)null); + _externalLoginRepoMock.Setup(r => r.GetByProviderAsync("oidc_entra", "entra999", default)).ReturnsAsync((ExternalLogin?)null); + + var dto = new ExternalLoginDto("oidc_entra", "entra999", "oidcuser", "oidcuser@example.com"); + + var result = await service.ExternalLoginAsync(dto); + + result.IsSuccess.Should().BeTrue(); + result.Value.User.Username.Should().Be("oidcuser1"); + } + + // ── OIDC Provider Config Tests ────────────────────────────────── + + [Fact] + public void OidcProviderConfig_IsConfigured_ShouldBeFalse_WhenMissingFields() + { + var config = new OidcProviderConfig + { + Name = "test", + Authority = "https://login.example.com", + ClientId = "", + ClientSecret = "secret" + }; + + config.IsConfigured.Should().BeFalse(); + } + + [Fact] + public void OidcProviderConfig_IsConfigured_ShouldBeTrue_WhenAllFieldsSet() + { + var config = new OidcProviderConfig + { + Name = "entra", + Authority = "https://login.microsoftonline.com/tenant", + ClientId = "client-id", + ClientSecret = "client-secret" + }; + + config.IsConfigured.Should().BeTrue(); + } + + [Fact] + public void OidcSettings_ConfiguredProviders_ShouldFilterIncomplete() + { + var settings = new OidcSettings + { + Providers = + [ + new OidcProviderConfig + { + Name = "complete", + Authority = "https://auth.example.com", + ClientId = "id", + ClientSecret = "secret" + }, + new OidcProviderConfig + { + Name = "incomplete", + Authority = "", + ClientId = "id", + ClientSecret = "secret" + } + ] + }; + + settings.ConfiguredProviders.Should().HaveCount(1); + settings.ConfiguredProviders[0].Name.Should().Be("complete"); + } + + // ── Cross-Provider Identity Isolation ──────────────────────────── + + [Fact] + public async Task ExternalLogin_ShouldNotLinkAcrossProviders() + { + var service = CreateService(); + + // User exists via GitHub but login comes from OIDC Entra with same provider user ID + _externalLoginRepoMock.Setup(r => r.GetByProviderAsync("oidc_entra", "shared-id", default)).ReturnsAsync((ExternalLogin?)null); + _externalLoginRepoMock.Setup(r => r.GetByProviderAsync("GitHub", "shared-id", default)).ReturnsAsync( + new ExternalLogin(Guid.NewGuid(), "GitHub", "shared-id")); + + var dto = new ExternalLoginDto("oidc_entra", "shared-id", "crossuser", "cross@example.com"); + + var result = await service.ExternalLoginAsync(dto); + + // Should create a new user, not link to GitHub user + result.IsSuccess.Should().BeTrue(); + } +} diff --git a/backend/tests/Taskdeck.Domain.Tests/Entities/MfaCredentialTests.cs b/backend/tests/Taskdeck.Domain.Tests/Entities/MfaCredentialTests.cs new file mode 100644 index 000000000..59a286de6 --- /dev/null +++ b/backend/tests/Taskdeck.Domain.Tests/Entities/MfaCredentialTests.cs @@ -0,0 +1,93 @@ +using FluentAssertions; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Domain.Tests.Entities; + +public class MfaCredentialTests +{ + [Fact] + public void Constructor_ShouldCreateCredential_WithValidInputs() + { + var userId = Guid.NewGuid(); + var secret = "JBSWY3DPEHPK3PXP"; + + var credential = new MfaCredential(userId, secret); + + credential.UserId.Should().Be(userId); + credential.Secret.Should().Be(secret); + credential.IsConfirmed.Should().BeFalse(); + credential.RecoveryCodes.Should().BeNull(); + } + + [Fact] + public void Constructor_ShouldThrow_WhenUserIdEmpty() + { + var act = () => new MfaCredential(Guid.Empty, "JBSWY3DPEHPK3PXP"); + + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + } + + [Fact] + public void Constructor_ShouldThrow_WhenSecretEmpty() + { + var act = () => new MfaCredential(Guid.NewGuid(), ""); + + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + } + + [Fact] + public void Constructor_ShouldThrow_WhenSecretTooLong() + { + var act = () => new MfaCredential(Guid.NewGuid(), new string('A', 513)); + + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + } + + [Fact] + public void Confirm_ShouldSetIsConfirmedToTrue() + { + var credential = new MfaCredential(Guid.NewGuid(), "JBSWY3DPEHPK3PXP"); + + credential.Confirm(); + + credential.IsConfirmed.Should().BeTrue(); + } + + [Fact] + public void SetRecoveryCodes_ShouldUpdateRecoveryCodes() + { + var credential = new MfaCredential(Guid.NewGuid(), "JBSWY3DPEHPK3PXP"); + var codes = "hash1,hash2,hash3"; + + credential.SetRecoveryCodes(codes); + + credential.RecoveryCodes.Should().Be(codes); + } + + [Fact] + public void SetRecoveryCodes_ShouldClearCodes_WhenEmpty() + { + var credential = new MfaCredential(Guid.NewGuid(), "JBSWY3DPEHPK3PXP"); + credential.SetRecoveryCodes("hash1,hash2,hash3"); + + credential.SetRecoveryCodes(""); + + credential.RecoveryCodes.Should().BeNull(); + } + + [Fact] + public void Revoke_ShouldSetIsConfirmedToFalse() + { + var credential = new MfaCredential(Guid.NewGuid(), "JBSWY3DPEHPK3PXP"); + credential.Confirm(); + + credential.Revoke(); + + credential.IsConfirmed.Should().BeFalse(); + } +} diff --git a/backend/tests/Taskdeck.Domain.Tests/Entities/UserTests.cs b/backend/tests/Taskdeck.Domain.Tests/Entities/UserTests.cs index 43ee4317c..b5ba2e4a7 100644 --- a/backend/tests/Taskdeck.Domain.Tests/Entities/UserTests.cs +++ b/backend/tests/Taskdeck.Domain.Tests/Entities/UserTests.cs @@ -73,4 +73,28 @@ public void UpdatePassword_ShouldUpdateHash() // Assert user.PasswordHash.Should().Be("new-hash"); } + + [Fact] + public void MfaEnabled_ShouldDefaultToFalse() + { + var user = new User("testuser", "test@example.com", "hash123"); + user.MfaEnabled.Should().BeFalse(); + } + + [Fact] + public void EnableMfa_ShouldSetMfaEnabledToTrue() + { + var user = new User("testuser", "test@example.com", "hash123"); + user.EnableMfa(); + user.MfaEnabled.Should().BeTrue(); + } + + [Fact] + public void DisableMfa_ShouldSetMfaEnabledToFalse() + { + var user = new User("testuser", "test@example.com", "hash123"); + user.EnableMfa(); + user.DisableMfa(); + user.MfaEnabled.Should().BeFalse(); + } } diff --git a/docs/decisions/ADR-0029-oidc-mfa-pluggable-identity.md b/docs/decisions/ADR-0029-oidc-mfa-pluggable-identity.md new file mode 100644 index 000000000..c806c4fbb --- /dev/null +++ b/docs/decisions/ADR-0029-oidc-mfa-pluggable-identity.md @@ -0,0 +1,69 @@ +# ADR-0029: OIDC/SSO Integration with Optional TOTP MFA + +- **Status**: Accepted +- **Date**: 2026-04-09 +- **Deciders**: Project maintainers + +## Context + +Taskdeck has GitHub OAuth for external authentication but lacks support for enterprise identity providers (Microsoft Entra ID, Google Workspace, generic OIDC). As the platform moves toward hosted/cloud deployment (ADR-0014), organizations need SSO integration with their existing identity infrastructure. Additionally, sensitive actions (password change, account deletion) lack a second verification factor, leaving accounts vulnerable to session hijacking. + +The existing authentication architecture uses JWT tokens with claims-first identity (ADR-0002) and already supports external login linking via the `ExternalLogin` entity. The challenge is extending this to support arbitrary OIDC providers while maintaining the security guarantees of the current system. + +## Decision + +### OIDC Provider Integration + +Adopt a **pluggable OIDC provider factory** pattern: +- OIDC providers are configured via `appsettings.json` under the `Oidc:Providers` array +- Each provider specifies Authority, ClientId, ClientSecret, Scopes, and CallbackPath +- Providers are registered as ASP.NET Core `AddOpenIdConnect` authentication schemes at startup +- Provider naming convention: `Oidc_{ProviderName}` for the authentication scheme +- The existing `ExternalLoginAsync` flow handles user creation/linking for all providers (GitHub + OIDC) +- OIDC is **disabled by default** -- no configuration means no OIDC endpoints are active + +### Identity Mapping + +- External identity is mapped via `ExternalLogin` entity keyed by `(Provider, ProviderUserId)` +- **No auto-linking by email** -- an OIDC login with a matching email creates a new Taskdeck user to prevent account takeover (consistent with existing GitHub OAuth security posture) +- Username collisions are resolved by appending numeric suffixes (capped at 100 attempts, then GUID fallback) +- Provider-specific prefixing (`oidc_{ProviderName}`) isolates identity namespaces across providers + +### MFA Integration + +Adopt **TOTP-based MFA** (RFC 6238) with these properties: +- MFA is **always optional** unless the administrator enables `MfaPolicy:RequireMfaForSensitiveActions` +- MFA setup flow: generate secret -> display QR/secret -> user confirms with TOTP code -> credential saved +- 8 single-use recovery codes generated at setup time (bcrypt-hashed at rest) +- TOTP validation uses constant-time comparison with configurable time window tolerance (default: +/- 1 step) +- `MfaCredential` entity stores per-user TOTP secrets with confirmation state +- `User.MfaEnabled` flag tracks whether MFA is active for policy decisions +- MFA credential is cascade-deleted with its user + +### Authorization Code Flow + +Both OIDC and GitHub OAuth share the same short-lived authorization code pattern: +- Provider callback generates a 60-second, single-use code stored in a `ConcurrentDictionary` +- Frontend exchanges the code via POST for a JWT token +- JWT is never exposed in URLs + +## Alternatives Considered + +- **WebAuthn/FIDO2**: Superior security but significantly higher implementation complexity and requires client-side credential storage. Deferred to a future phase -- the TOTP infrastructure can coexist with WebAuthn later. +- **Session-based MFA (cookie)**: Would require session state infrastructure that conflicts with the JWT-stateless design. Rejected. +- **Auto-link OIDC accounts by email**: Tempting for UX but creates an account takeover vector since OIDC providers may not verify email ownership with sufficient rigor. Rejected (consistent with ADR-0002 claims-first security posture). +- **Single OIDC provider hardcoded**: Simpler but forces re-deployment to change providers. The pluggable array approach supports multi-tenant scenarios. + +## Consequences + +- **Positive**: Organizations can authenticate via their existing identity provider; MFA reduces session hijack risk; config-gated design means zero cost for local-first users. +- **Negative**: TOTP shared secrets stored in SQLite require careful rotation procedures if the database is compromised; the `ConcurrentDictionary` auth code store does not survive process restarts. +- **Neutral**: Frontend gains OIDC login buttons and MFA setup/challenge components; existing GitHub OAuth flow is unaffected. + +## References + +- ADR-0002: Claims-First Identity Model +- ADR-0009: Session Token Storage +- ADR-0014: Platform Expansion -- Four Pillars +- Issue #82: SEC-07: SSO/OIDC integration with optional MFA policy +- RFC 6238: TOTP: Time-Based One-Time Password Algorithm diff --git a/frontend/taskdeck-web/src/api/authApi.ts b/frontend/taskdeck-web/src/api/authApi.ts index 183737e07..3c4d9e23a 100644 --- a/frontend/taskdeck-web/src/api/authApi.ts +++ b/frontend/taskdeck-web/src/api/authApi.ts @@ -1,5 +1,15 @@ import http from './http' -import type { LoginRequest, RegisterRequest, ChangePasswordRequest, AuthResponse, AuthProviders, LinkedAccount } from '../types/auth' +import type { + LoginRequest, + RegisterRequest, + ChangePasswordRequest, + AuthResponse, + AuthProviders, + LinkedAccount, + MfaStatus, + MfaSetupResponse, + MfaVerifyRequest, +} from '../types/auth' export const authApi = { async login(credentials: LoginRequest): Promise { @@ -26,6 +36,11 @@ export const authApi = { return data }, + async exchangeOidcCode(code: string): Promise { + const { data } = await http.post('/auth/oidc/exchange', { code }) + return data + }, + async getLinkedAccounts(): Promise { const { data } = await http.get('/auth/linked-accounts') return data @@ -39,4 +54,27 @@ export const authApi = { async unlinkGitHub(): Promise { await http.delete('/auth/github/link') }, + + // MFA endpoints + async getMfaStatus(): Promise { + const { data } = await http.get('/auth/mfa/status') + return data + }, + + async setupMfa(): Promise { + const { data } = await http.post('/auth/mfa/setup') + return data + }, + + async confirmMfa(request: MfaVerifyRequest): Promise { + await http.post('/auth/mfa/confirm', request) + }, + + async verifyMfa(request: MfaVerifyRequest): Promise { + await http.post('/auth/mfa/verify', request) + }, + + async disableMfa(request: MfaVerifyRequest): Promise { + await http.post('/auth/mfa/disable', request) + }, } diff --git a/frontend/taskdeck-web/src/components/MfaChallengeModal.vue b/frontend/taskdeck-web/src/components/MfaChallengeModal.vue new file mode 100644 index 000000000..55351cd5c --- /dev/null +++ b/frontend/taskdeck-web/src/components/MfaChallengeModal.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/frontend/taskdeck-web/src/components/MfaSetup.vue b/frontend/taskdeck-web/src/components/MfaSetup.vue new file mode 100644 index 000000000..ba62ec921 --- /dev/null +++ b/frontend/taskdeck-web/src/components/MfaSetup.vue @@ -0,0 +1,390 @@ + + + + + diff --git a/frontend/taskdeck-web/src/store/sessionStore.ts b/frontend/taskdeck-web/src/store/sessionStore.ts index 8827c94ae..93391d4c4 100644 --- a/frontend/taskdeck-web/src/store/sessionStore.ts +++ b/frontend/taskdeck-web/src/store/sessionStore.ts @@ -220,6 +220,24 @@ export const useSessionStore = defineStore('session', () => { } } + async function exchangeOidcCode(code: string) { + try { + loading.value = true + error.value = null + const response = await authApi.exchangeOidcCode(code) + setSession(response) + toast.success('Signed in successfully') + return response + } catch (e: unknown) { + const msg = getErrorMessage(e, 'SSO sign-in failed') + error.value = msg + toast.error(msg) + throw e + } finally { + loading.value = false + } + } + function logout() { clearSession() toast.info('Logged out') @@ -249,6 +267,7 @@ export const useSessionStore = defineStore('session', () => { register, changePassword, exchangeOAuthCode, + exchangeOidcCode, logout, restoreSession, clearSession, diff --git a/frontend/taskdeck-web/src/tests/components/MfaSetup.spec.ts b/frontend/taskdeck-web/src/tests/components/MfaSetup.spec.ts new file mode 100644 index 000000000..5e3ee39fe --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/MfaSetup.spec.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import MfaSetup from '../../components/MfaSetup.vue' + +const authApiMock = vi.hoisted(() => ({ + getMfaStatus: vi.fn(), + setupMfa: vi.fn(), + confirmMfa: vi.fn(), + disableMfa: vi.fn(), +})) + +vi.mock('../../api/authApi', () => ({ + authApi: authApiMock, +})) + +vi.mock('../../utils/errorMessage', () => ({ + getErrorMessage: (error: unknown, fallback: string) => + error instanceof Error && error.message ? error.message : fallback, +})) + +async function waitForUi() { + await Promise.resolve() + await Promise.resolve() +} + +describe('MfaSetup', () => { + beforeEach(() => { + vi.clearAllMocks() + authApiMock.getMfaStatus.mockResolvedValue({ + isEnabled: false, + isSetupAvailable: true, + }) + authApiMock.setupMfa.mockResolvedValue({ + sharedSecret: 'ABCDEF123456', + qrCodeUri: 'otpauth://totp/Taskdeck:testuser?secret=ABCDEF123456&issuer=Taskdeck', + recoveryCodes: ['1111-2222', '3333-4444'], + }) + }) + + it('shows shared-secret guidance instead of claiming a QR code is rendered', async () => { + const wrapper = mount(MfaSetup) + await waitForUi() + + await wrapper.find('button').trigger('click') + await waitForUi() + + expect(wrapper.text()).toContain('Add this secret to your authenticator app') + expect(wrapper.text()).toContain('Show provisioning URI') + expect(wrapper.text()).not.toContain('Scan the QR code below') + }) +}) diff --git a/frontend/taskdeck-web/src/tests/views/LoginView.spec.ts b/frontend/taskdeck-web/src/tests/views/LoginView.spec.ts index 27aba7bed..865b0ecff 100644 --- a/frontend/taskdeck-web/src/tests/views/LoginView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/LoginView.spec.ts @@ -18,6 +18,7 @@ const sessionMock = vi.hoisted(() => ({ loginAsDemo: vi.fn(), register: vi.fn(), exchangeOAuthCode: vi.fn(), + exchangeOidcCode: vi.fn(), error: null as string | null, })) @@ -74,7 +75,8 @@ describe('LoginView', () => { sessionMock.login.mockResolvedValue(undefined) sessionMock.loginAsDemo.mockReturnValue(undefined) sessionMock.exchangeOAuthCode.mockResolvedValue(undefined) - authApiMock.getProviders.mockResolvedValue({ gitHub: false }) + sessionMock.exchangeOidcCode.mockResolvedValue(undefined) + authApiMock.getProviders.mockResolvedValue({ gitHub: false, oidc: [] }) }) it('renders the sign-in title', async () => { @@ -284,6 +286,21 @@ describe('LoginView', () => { }) }) + describe('OIDC code exchange', () => { + it('calls exchangeOidcCode when oauth_provider indicates an OIDC flow', async () => { + routeMock.query = { oauth_code: 'oidc-code-abc123', oauth_provider: 'oidc' } + sessionMock.exchangeOidcCode.mockResolvedValue(undefined) + + mount(LoginView) + await waitForUi() + await waitForUi() + + expect(sessionMock.exchangeOidcCode).toHaveBeenCalledWith('oidc-code-abc123') + expect(sessionMock.exchangeOAuthCode).not.toHaveBeenCalled() + expect(routerMocks.push).toHaveBeenCalledWith('/workspace/home') + }) + }) + describe('demo mode', () => { it('renders the Enter Demo button and hides the login form', async () => { isDemoModeMock.value = true diff --git a/frontend/taskdeck-web/src/types/auth.ts b/frontend/taskdeck-web/src/types/auth.ts index b19b20d8a..099d75f87 100644 --- a/frontend/taskdeck-web/src/types/auth.ts +++ b/frontend/taskdeck-web/src/types/auth.ts @@ -39,8 +39,29 @@ export interface SessionState { expiresAt: string | null } +export interface OidcProviderInfo { + name: string + displayName: string +} + export interface AuthProviders { gitHub: boolean + oidc: OidcProviderInfo[] +} + +export interface MfaStatus { + isEnabled: boolean + isSetupAvailable: boolean +} + +export interface MfaSetupResponse { + sharedSecret: string + qrCodeUri: string + recoveryCodes: string[] +} + +export interface MfaVerifyRequest { + code: string } export interface LinkedAccount { diff --git a/frontend/taskdeck-web/src/views/LoginView.vue b/frontend/taskdeck-web/src/views/LoginView.vue index 47fbb34ed..1eb302b90 100644 --- a/frontend/taskdeck-web/src/views/LoginView.vue +++ b/frontend/taskdeck-web/src/views/LoginView.vue @@ -1,4 +1,5 @@ @@ -114,12 +133,13 @@ onMounted(async () => {