From 95054ad76d53491b9d470633d60b128e074d33be Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:04:00 +0100 Subject: [PATCH 01/18] Add MfaCredential entity and MfaEnabled flag to User Domain layer for TOTP-based MFA: MfaCredential entity tracks per-user TOTP secrets with confirmation state and recovery codes. User entity gains MfaEnabled boolean and EnableMfa/DisableMfa methods. --- .../Taskdeck.Domain/Entities/MfaCredential.cs | 89 +++++++++++++++++++ backend/src/Taskdeck.Domain/Entities/User.cs | 17 ++++ 2 files changed, 106 insertions(+) create mode 100644 backend/src/Taskdeck.Domain/Entities/MfaCredential.cs diff --git a/backend/src/Taskdeck.Domain/Entities/MfaCredential.cs b/backend/src/Taskdeck.Domain/Entities/MfaCredential.cs new file mode 100644 index 000000000..b5848bb35 --- /dev/null +++ b/backend/src/Taskdeck.Domain/Entities/MfaCredential.cs @@ -0,0 +1,89 @@ +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. +/// The shared secret is stored encrypted at rest by the infrastructure layer. +/// +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. + /// + public void SetRecoveryCodes(string hashedRecoveryCodes) + { + if (string.IsNullOrWhiteSpace(hashedRecoveryCodes)) + throw new DomainException(ErrorCodes.ValidationError, "Recovery codes cannot be empty"); + + RecoveryCodes = hashedRecoveryCodes; + Touch(); + } + + /// + /// Revokes this MFA credential by clearing the secret and marking as unconfirmed. + /// The entity remains for audit trail purposes. + /// + 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 From a4b7d90e898ba42e0fd395348aff5824f15f97ef Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:05:44 +0100 Subject: [PATCH 02/18] Add application layer for OIDC and MFA OidcProviderSettings for pluggable OIDC provider configs, MfaPolicySettings for optional MFA policy, MfaService with TOTP generation/validation, IMfaCredentialRepository, and supporting DTOs. --- .../src/Taskdeck.Application/DTOs/MfaDtos.cs | 24 ++ .../src/Taskdeck.Application/DTOs/OidcDtos.cs | 9 + .../Interfaces/IMfaCredentialRepository.cs | 9 + .../Interfaces/IUnitOfWork.cs | 1 + .../Services/MfaPolicySettings.cs | 35 ++ .../Services/MfaService.cs | 339 ++++++++++++++++++ .../Services/OidcProviderSettings.cs | 39 ++ 7 files changed, 456 insertions(+) create mode 100644 backend/src/Taskdeck.Application/DTOs/MfaDtos.cs create mode 100644 backend/src/Taskdeck.Application/DTOs/OidcDtos.cs create mode 100644 backend/src/Taskdeck.Application/Interfaces/IMfaCredentialRepository.cs create mode 100644 backend/src/Taskdeck.Application/Services/MfaPolicySettings.cs create mode 100644 backend/src/Taskdeck.Application/Services/MfaService.cs create mode 100644 backend/src/Taskdeck.Application/Services/OidcProviderSettings.cs 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 b6ada47b6..7dbd00664 100644 --- a/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs +++ b/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs @@ -28,6 +28,7 @@ public interface IUnitOfWork IKnowledgeChunkRepository KnowledgeChunks { get; } IExternalLoginRepository ExternalLogins { 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..ff0981821 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/MfaService.cs @@ -0,0 +1,339 @@ +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"; + + 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)) + 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 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"); + + if (!ValidateTotp(credential.Secret, code)) + 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)) + 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) + { + 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 expectedCode = ComputeTotp(secretBytes, currentStep + i); + if (ConstantTimeEquals(code, expectedCode)) + return true; + } + + return false; + } + + 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)) + 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) + : " "); // Keep non-empty to satisfy domain validation + 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(); +} From ed16cb8e29ef914b3ed9ec1abb880c820d21221b Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:09:02 +0100 Subject: [PATCH 03/18] Add infrastructure for MFA: repository, EF config, migration MfaCredentialRepository, EF configuration, migration adding MfaCredentials table and User.MfaEnabled column, UnitOfWork and DI registration. --- .../DependencyInjection.cs | 1 + .../20260409120000_AddMfaCredentials.cs | 62 +++++++++++++++++++ .../TaskdeckDbContextModelSnapshot.cs | 49 +++++++++++++++ .../MfaCredentialConfiguration.cs | 44 +++++++++++++ .../Configurations/UserConfiguration.cs | 4 ++ .../Persistence/TaskdeckDbContext.cs | 1 + .../Repositories/MfaCredentialRepository.cs | 31 ++++++++++ .../Repositories/UnitOfWork.cs | 5 +- 8 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 backend/src/Taskdeck.Infrastructure/Migrations/20260409120000_AddMfaCredentials.cs create mode 100644 backend/src/Taskdeck.Infrastructure/Persistence/Configurations/MfaCredentialConfiguration.cs create mode 100644 backend/src/Taskdeck.Infrastructure/Repositories/MfaCredentialRepository.cs diff --git a/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs b/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs index fee4d4734..bc1065408 100644 --- a/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs +++ b/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs @@ -44,6 +44,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.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 4144a5c23..c38731cd8 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") @@ -1438,6 +1473,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) @@ -1705,6 +1745,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 6509204ab..384bc91b5 100644 --- a/backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs +++ b/backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs @@ -40,6 +40,7 @@ public TaskdeckDbContext(DbContextOptions options) : base(opt public DbSet KnowledgeChunks => Set(); public DbSet ExternalLogins => 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 9cd2cf84a..0d69594ea 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs @@ -38,7 +38,8 @@ public UnitOfWork( IKnowledgeDocumentRepository knowledgeDocuments, IKnowledgeChunkRepository knowledgeChunks, IExternalLoginRepository externalLogins, - IApiKeyRepository apiKeys) + IApiKeyRepository apiKeys, + IMfaCredentialRepository mfaCredentials) { _context = context; Boards = boards; @@ -67,6 +68,7 @@ public UnitOfWork( KnowledgeChunks = knowledgeChunks; ExternalLogins = externalLogins; ApiKeys = apiKeys; + MfaCredentials = mfaCredentials; } public IBoardRepository Boards { get; } @@ -95,6 +97,7 @@ public UnitOfWork( public IKnowledgeChunkRepository KnowledgeChunks { get; } public IExternalLoginRepository ExternalLogins { get; } public IApiKeyRepository ApiKeys { get; } + public IMfaCredentialRepository MfaCredentials { get; } public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { From a70649f136578290a0dfda899b764ec25898f8d4 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:14:40 +0100 Subject: [PATCH 04/18] Add OIDC and MFA endpoints to API layer MfaController with setup/confirm/verify/disable endpoints, OIDC login/callback endpoints in AuthController, OpenIdConnect provider registration, settings binding for OIDC and MFA policy configuration. --- .../Controllers/AuthController.cs | 134 +++++++++++++++++- .../Taskdeck.Api/Controllers/MfaController.cs | 116 +++++++++++++++ .../ApplicationServiceRegistration.cs | 1 + .../Extensions/AuthenticationRegistration.cs | 39 ++++- .../Extensions/SettingsRegistration.cs | 9 +- backend/src/Taskdeck.Api/Program.cs | 7 +- backend/src/Taskdeck.Api/Taskdeck.Api.csproj | 1 + 7 files changed, 299 insertions(+), 8 deletions(-) create mode 100644 backend/src/Taskdeck.Api/Controllers/MfaController.cs diff --git a/backend/src/Taskdeck.Api/Controllers/AuthController.cs b/backend/src/Taskdeck.Api/Controllers/AuthController.cs index 06c544690..51c6629e8 100644 --- a/backend/src/Taskdeck.Api/Controllers/AuthController.cs +++ b/backend/src/Taskdeck.Api/Controllers/AuthController.cs @@ -18,6 +18,7 @@ namespace Taskdeck.Api.Controllers; public record ChangePasswordRequest(string CurrentPassword, string NewPassword); public record ExchangeCodeRequest(string Code); +public record OidcExchangeCodeRequest(string Code, string Provider); /// /// Authentication endpoints — register, login, change password, and GitHub OAuth flow. @@ -30,16 +31,22 @@ public class AuthController : AuthenticatedControllerBase { private readonly AuthenticationService _authService; private readonly GitHubOAuthSettings _gitHubOAuthSettings; + private readonly OidcSettings _oidcSettings; // Short-lived, single-use authorization codes to avoid exposing JWT in URLs. // Key: code, Value: (token, expiry). Codes expire after 60 seconds. private static readonly ConcurrentDictionary _authCodes = new(); - public AuthController(AuthenticationService authService, GitHubOAuthSettings gitHubOAuthSettings, IUserContext userContext) + public AuthController( + AuthenticationService authService, + GitHubOAuthSettings gitHubOAuthSettings, + OidcSettings oidcSettings, + IUserContext userContext) : base(userContext) { _authService = authService; _gitHubOAuthSettings = gitHubOAuthSettings; + _oidcSettings = oidcSettings; } /// @@ -229,17 +236,138 @@ public IActionResult ExchangeCode([FromBody] ExchangeCodeRequest request) } /// - /// 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(schemeName); + + var code = GenerateAuthCode(); + _authCodes[code] = (result.Value, DateTimeOffset.UtcNow.AddSeconds(60)); + CleanupExpiredCodes(); + + var safeReturnUrl = !string.IsNullOrWhiteSpace(returnUrl) && Url.IsLocalUrl(returnUrl) + ? returnUrl + : "/"; + + var separator = safeReturnUrl.Contains('?') ? "&" : "?"; + return Redirect($"{safeReturnUrl}{separator}oauth_code={Uri.EscapeDataString(code)}"); + } + + /// + /// Exchanges a short-lived OIDC authorization code for a JWT token. + /// Reuses the same code store as GitHub OAuth. + /// + [HttpPost("oidc/exchange")] + [EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)] + public IActionResult OidcExchangeCode([FromBody] ExchangeCodeRequest request) + { + if (string.IsNullOrWhiteSpace(request.Code)) + return BadRequest(new ApiErrorResponse(ErrorCodes.ValidationError, "Code is required")); + + if (!_authCodes.TryRemove(request.Code, out var entry)) + return Unauthorized(new ApiErrorResponse(ErrorCodes.AuthenticationFailed, "Invalid or expired code")); + + if (DateTimeOffset.UtcNow > entry.Expiry) + return Unauthorized(new ApiErrorResponse(ErrorCodes.AuthenticationFailed, "Code has expired")); + + return Ok(entry.Result); + } + 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 720f1bff7..00b347c27 100644 --- a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs @@ -18,6 +18,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 88ad65467..6fe6b4326 100644 --- a/backend/src/Taskdeck.Api/Extensions/AuthenticationRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/AuthenticationRegistration.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.OAuth.Claims; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.IdentityModel.Tokens; using Taskdeck.Api.Contracts; using Taskdeck.Application.Services; @@ -16,7 +17,8 @@ public static class AuthenticationRegistration 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 || @@ -113,6 +115,41 @@ 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.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 37067095a..ad6ac1bd3 100644 --- a/backend/src/Taskdeck.Api/Extensions/SettingsRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/SettingsRegistration.cs @@ -11,7 +11,8 @@ public static IServiceCollection AddTaskdeckSettings( out ObservabilitySettings observabilitySettings, out RateLimitingSettings rateLimitingSettings, out JwtSettings jwtSettings, - out GitHubOAuthSettings gitHubOAuthSettings) + out GitHubOAuthSettings gitHubOAuthSettings, + out OidcSettings oidcSettings) { observabilitySettings = configuration .GetSection("Observability") @@ -45,6 +46,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 b8c3ca5d9..fa6f41fe3 100644 --- a/backend/src/Taskdeck.Api/Program.cs +++ b/backend/src/Taskdeck.Api/Program.cs @@ -157,7 +157,8 @@ out var observabilitySettings, out var rateLimitingSettings, out var jwtSettings, - out var gitHubOAuthSettings); + out var gitHubOAuthSettings, + out var oidcSettings); // Add Infrastructure (DbContext, Repositories) builder.Services.AddInfrastructure(builder.Configuration); @@ -184,8 +185,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 f978150c6..8f0e0b28a 100644 --- a/backend/src/Taskdeck.Api/Taskdeck.Api.csproj +++ b/backend/src/Taskdeck.Api/Taskdeck.Api.csproj @@ -19,6 +19,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive From f149b82ec5b95d3667c2d51c3e2b14454b48ec1e Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:14:49 +0100 Subject: [PATCH 05/18] Fix existing tests for IUnitOfWork.MfaCredentials and AuthController changes Add MfaCredentials property to FakeUnitOfWork implementations across test files. Update AuthController instantiation to include OidcSettings parameter. --- .../ActiveUserValidationMiddlewareTests.cs | 1 + .../tests/Taskdeck.Api.Tests/AuthControllerEdgeCaseTests.cs | 6 +++--- .../Taskdeck.Api.Tests/LlmQueueToProposalWorkerTests.cs | 1 + .../OutboundWebhookDeliveryWorkerReliabilityTests.cs | 1 + .../OutboundWebhookDeliveryWorkerTests.cs | 1 + .../Taskdeck.Api.Tests/OutboundWebhookHmacDeliveryTests.cs | 1 + .../ProposalHousekeepingWorkerEdgeCaseTests.cs | 1 + .../Taskdeck.Api.Tests/ProposalHousekeepingWorkerTests.cs | 1 + backend/tests/Taskdeck.Api.Tests/WorkerResilienceTests.cs | 2 ++ 9 files changed, 12 insertions(+), 3 deletions(-) diff --git a/backend/tests/Taskdeck.Api.Tests/ActiveUserValidationMiddlewareTests.cs b/backend/tests/Taskdeck.Api.Tests/ActiveUserValidationMiddlewareTests.cs index 6b5b2882f..6c8836ddd 100644 --- a/backend/tests/Taskdeck.Api.Tests/ActiveUserValidationMiddlewareTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/ActiveUserValidationMiddlewareTests.cs @@ -301,6 +301,7 @@ public StubUnitOfWork(User? userToReturn) public IKnowledgeChunkRepository KnowledgeChunks => throw new NotImplementedException(); public IExternalLoginRepository ExternalLogins => 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 05a29d18f..61b364445 100644 --- a/backend/tests/Taskdeck.Api.Tests/AuthControllerEdgeCaseTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/AuthControllerEdgeCaseTests.cs @@ -275,7 +275,7 @@ public async Task TokenValidationMiddleware_ShouldPassThrough_WhenClaimsHaveNoUs public async Task Login_ShouldReturn401_WhenBodyIsNull() { var authService = CreateMockAuthService(); - var controller = new AuthController(authService.Object, CreateGitHubSettings(false), CreateMockUserContext().Object); + var controller = new AuthController(authService.Object, CreateGitHubSettings(false), new OidcSettings(), CreateMockUserContext().Object); var result = await controller.Login(null); @@ -288,7 +288,7 @@ public async Task Login_ShouldReturn401_WhenBodyIsNull() public async Task Login_ShouldReturn401_WhenFieldsEmpty() { var authService = CreateMockAuthService(); - var controller = new AuthController(authService.Object, CreateGitHubSettings(false), CreateMockUserContext().Object); + var controller = new AuthController(authService.Object, CreateGitHubSettings(false), new OidcSettings(), CreateMockUserContext().Object); var result = await controller.Login(new LoginDto("", "")); @@ -305,7 +305,7 @@ private static AuthController CreateAuthController(bool gitHubConfigured = false { var authServiceMock = CreateMockAuthService(); var gitHubSettings = CreateGitHubSettings(gitHubConfigured); - return new AuthController(authServiceMock.Object, gitHubSettings, CreateMockUserContext().Object); + return new AuthController(authServiceMock.Object, gitHubSettings, new OidcSettings(), CreateMockUserContext().Object); } private static Mock CreateMockAuthService() diff --git a/backend/tests/Taskdeck.Api.Tests/LlmQueueToProposalWorkerTests.cs b/backend/tests/Taskdeck.Api.Tests/LlmQueueToProposalWorkerTests.cs index 827932f4e..b08ebdd39 100644 --- a/backend/tests/Taskdeck.Api.Tests/LlmQueueToProposalWorkerTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/LlmQueueToProposalWorkerTests.cs @@ -844,6 +844,7 @@ public FakeUnitOfWork(ILlmQueueRepository llmQueue) public IKnowledgeChunkRepository KnowledgeChunks => null!; public IExternalLoginRepository ExternalLogins => 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 275b67446..7833b0512 100644 --- a/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerReliabilityTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerReliabilityTests.cs @@ -643,6 +643,7 @@ public FakeUnitOfWork(IOutboundWebhookDeliveryRepository deliveries) public IKnowledgeChunkRepository KnowledgeChunks => null!; public IExternalLoginRepository ExternalLogins => 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 f6a004ab3..7360dd4c1 100644 --- a/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerTests.cs @@ -549,6 +549,7 @@ public FakeUnitOfWork(IOutboundWebhookDeliveryRepository outboundWebhookDelivery public IKnowledgeChunkRepository KnowledgeChunks => null!; public IExternalLoginRepository ExternalLogins => 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 3c1701121..77ba10b0c 100644 --- a/backend/tests/Taskdeck.Api.Tests/OutboundWebhookHmacDeliveryTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/OutboundWebhookHmacDeliveryTests.cs @@ -536,6 +536,7 @@ public StubUnitOfWork(IOutboundWebhookDeliveryRepository deliveries) public IKnowledgeChunkRepository KnowledgeChunks => null!; public IExternalLoginRepository ExternalLogins => 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 eff5025cb..40b4efc50 100644 --- a/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerEdgeCaseTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerEdgeCaseTests.cs @@ -342,6 +342,7 @@ public FakeUnitOfWork(IAutomationProposalRepository repo) public IKnowledgeChunkRepository KnowledgeChunks => null!; public IExternalLoginRepository ExternalLogins => 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 302f91ab7..7f41270a6 100644 --- a/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerTests.cs @@ -220,6 +220,7 @@ public FakeUnitOfWork(IAutomationProposalRepository automationProposalRepository public IKnowledgeChunkRepository KnowledgeChunks => null!; public IExternalLoginRepository ExternalLogins => 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 fd3f809ef..81dc1ffbd 100644 --- a/backend/tests/Taskdeck.Api.Tests/WorkerResilienceTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/WorkerResilienceTests.cs @@ -596,6 +596,7 @@ private sealed class FakeUnitOfWorkWithLlmQueue : IUnitOfWork public IKnowledgeChunkRepository KnowledgeChunks => null!; public IExternalLoginRepository ExternalLogins => 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; @@ -631,6 +632,7 @@ private sealed class FakeUnitOfWorkWithProposals : IUnitOfWork public IKnowledgeChunkRepository KnowledgeChunks => null!; public IExternalLoginRepository ExternalLogins => 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; From 59d9e0034406836e5de088310c90ac6e5cf4580e Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:16:54 +0100 Subject: [PATCH 06/18] Add security tests for MFA and OIDC MfaServiceTests covering setup, confirm, disable, verify, TOTP validation, base32 encoding, status, and policy checks. OidcSecurityTests covering provider validation, email collision protection, cross-provider isolation, username deduplication, inactive user rejection, and config validation. MfaCredentialTests for domain entity validation. User MFA flag tests. --- .../Services/MfaServiceTests.cs | 315 ++++++++++++++++++ .../Services/OidcSecurityTests.cs | 240 +++++++++++++ .../Entities/MfaCredentialTests.cs | 93 ++++++ .../Entities/UserTests.cs | 24 ++ 4 files changed, 672 insertions(+) create mode 100644 backend/tests/Taskdeck.Application.Tests/Services/MfaServiceTests.cs create mode 100644 backend/tests/Taskdeck.Application.Tests/Services/OidcSecurityTests.cs create mode 100644 backend/tests/Taskdeck.Domain.Tests/Entities/MfaCredentialTests.cs 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..a5f5818a8 --- /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_ShouldThrow_WhenEmpty() + { + var credential = new MfaCredential(Guid.NewGuid(), "JBSWY3DPEHPK3PXP"); + + var act = () => credential.SetRecoveryCodes(""); + + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + } + + [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(); + } } From 58d93932d9ace75166ecddce41908d2d242e6c90 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 19:20:11 +0100 Subject: [PATCH 07/18] Add frontend OIDC and MFA support OIDC login buttons on LoginView (config-gated), MFA types and API client methods, MfaSetup component for settings, MfaChallengeModal for protected action verification, sessionStore OIDC code exchange. --- frontend/taskdeck-web/src/api/authApi.ts | 39 +- .../src/components/MfaChallengeModal.vue | 190 ++++++++++ .../taskdeck-web/src/components/MfaSetup.vue | 346 ++++++++++++++++++ .../taskdeck-web/src/store/sessionStore.ts | 19 + frontend/taskdeck-web/src/types/auth.ts | 21 ++ frontend/taskdeck-web/src/views/LoginView.vue | 57 ++- 6 files changed, 668 insertions(+), 4 deletions(-) create mode 100644 frontend/taskdeck-web/src/components/MfaChallengeModal.vue create mode 100644 frontend/taskdeck-web/src/components/MfaSetup.vue diff --git a/frontend/taskdeck-web/src/api/authApi.ts b/frontend/taskdeck-web/src/api/authApi.ts index 7bcaaaf31..0143756b7 100644 --- a/frontend/taskdeck-web/src/api/authApi.ts +++ b/frontend/taskdeck-web/src/api/authApi.ts @@ -1,5 +1,14 @@ import http from './http' -import type { LoginRequest, RegisterRequest, ChangePasswordRequest, AuthResponse, AuthProviders } from '../types/auth' +import type { + LoginRequest, + RegisterRequest, + ChangePasswordRequest, + AuthResponse, + AuthProviders, + MfaStatus, + MfaSetupResponse, + MfaVerifyRequest, +} from '../types/auth' export const authApi = { async login(credentials: LoginRequest): Promise { @@ -25,4 +34,32 @@ export const authApi = { const { data } = await http.post('/auth/github/exchange', { code }) return data }, + + async exchangeOidcCode(code: string): Promise { + const { data } = await http.post('/auth/oidc/exchange', { code }) + return data + }, + + // 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..fa310e851 --- /dev/null +++ b/frontend/taskdeck-web/src/components/MfaChallengeModal.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/frontend/taskdeck-web/src/components/MfaSetup.vue b/frontend/taskdeck-web/src/components/MfaSetup.vue new file mode 100644 index 000000000..84af7a543 --- /dev/null +++ b/frontend/taskdeck-web/src/components/MfaSetup.vue @@ -0,0 +1,346 @@ + + + + + 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/types/auth.ts b/frontend/taskdeck-web/src/types/auth.ts index 2cf4c2a53..f4b0923e7 100644 --- a/frontend/taskdeck-web/src/types/auth.ts +++ b/frontend/taskdeck-web/src/types/auth.ts @@ -39,6 +39,27 @@ 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 } diff --git a/frontend/taskdeck-web/src/views/LoginView.vue b/frontend/taskdeck-web/src/views/LoginView.vue index 47fbb34ed..7c7a922cd 100644 --- a/frontend/taskdeck-web/src/views/LoginView.vue +++ b/frontend/taskdeck-web/src/views/LoginView.vue @@ -10,11 +10,14 @@ const router = useRouter() const route = useRoute() const session = useSessionStore() +import type { OidcProviderInfo } from '../types/auth' + const username = ref('') const password = ref('') const formError = ref(null) const submitting = ref(false) const githubAvailable = ref(false) +const oidcProviders = ref([]) const oauthExchanging = ref(false) function navigateAfterLogin() { @@ -54,6 +57,15 @@ function startGitHubLogin() { window.location.href = `${apiBase}/auth/github/login?returnUrl=${encodeURIComponent(returnUrl)}` } +function startOidcLogin(providerName: string) { + const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api' + const redirect = [route.query.redirect].flat()[0] + const returnUrl = redirect + ? `/login?redirect=${encodeURIComponent(redirect)}` + : '/login' + window.location.href = `${apiBase}/auth/oidc/${encodeURIComponent(providerName)}/login?returnUrl=${encodeURIComponent(returnUrl)}` +} + async function handleOAuthCode(code: string) { oauthExchanging.value = true formError.value = null @@ -88,12 +100,13 @@ onMounted(async () => { return } - // Check if GitHub OAuth is available (non-blocking) + // Check available auth providers (non-blocking) try { const providers = await authApi.getProviders() githubAvailable.value = providers.gitHub === true + oidcProviders.value = Array.isArray(providers.oidc) ? providers.oidc : [] } catch { - // Silently ignore — GitHub button simply won't appear + // Silently ignore — provider buttons simply won't appear } }) @@ -118,8 +131,9 @@ onMounted(async () => {