Skip to content

Commit 573f512

Browse files
committed
Resolve merge conflicts with main after SSO/OIDC/MFA merge
Take main's versions for all non-cache files now that #813 provides OIDC/MFA infrastructure. Keep MfaCredentialTests fix with both empty-string and null clearing test cases.
2 parents 208c657 + 3413e80 commit 573f512

File tree

17 files changed

+957
-34
lines changed

17 files changed

+957
-34
lines changed

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

Lines changed: 180 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
namespace Taskdeck.Api.Controllers;
1919

20-
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
20+
public record ChangePasswordRequest(string CurrentPassword, string NewPassword, string? MfaCode = null);
2121
public record ExchangeCodeRequest(string Code);
2222
public record LinkExchangeRequest(string Code);
2323

@@ -32,13 +32,23 @@ public class AuthController : AuthenticatedControllerBase
3232
{
3333
private readonly AuthenticationService _authService;
3434
private readonly GitHubOAuthSettings _gitHubOAuthSettings;
35+
private readonly OidcSettings _oidcSettings;
36+
private readonly MfaService _mfaService;
3537
private readonly IUnitOfWork _unitOfWork;
3638

37-
public AuthController(AuthenticationService authService, GitHubOAuthSettings gitHubOAuthSettings, IUserContext userContext, IUnitOfWork unitOfWork)
39+
public AuthController(
40+
AuthenticationService authService,
41+
GitHubOAuthSettings gitHubOAuthSettings,
42+
OidcSettings oidcSettings,
43+
MfaService mfaService,
44+
IUserContext userContext,
45+
IUnitOfWork unitOfWork)
3846
: base(userContext)
3947
{
4048
_authService = authService;
4149
_gitHubOAuthSettings = gitHubOAuthSettings;
50+
_oidcSettings = oidcSettings;
51+
_mfaService = mfaService;
4252
_unitOfWork = unitOfWork;
4353
}
4454

@@ -93,24 +103,40 @@ public async Task<IActionResult> Register([FromBody] CreateUserDto dto)
93103
/// <summary>
94104
/// Change the password for the authenticated caller.
95105
/// The target user is always derived from the JWT — client-supplied user IDs are not accepted.
106+
/// When MFA is enabled and RequireMfaForSensitiveActions is true, a valid MFA code is required.
96107
/// </summary>
97-
/// <param name="request">Current and new password.</param>
108+
/// <param name="request">Current password, new password, and optional MFA code.</param>
98109
/// <response code="204">Password changed successfully.</response>
99110
/// <response code="400">Validation error.</response>
100111
/// <response code="401">Not authenticated or current password is incorrect.</response>
112+
/// <response code="403">MFA verification required but not provided or invalid.</response>
101113
/// <response code="429">Rate limit exceeded.</response>
102114
[HttpPost("change-password")]
103115
[Authorize]
104116
[EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)]
105117
[ProducesResponseType(StatusCodes.Status204NoContent)]
106118
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
107119
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)]
120+
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)]
108121
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
109122
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
110123
{
111124
if (!TryGetCurrentUserId(out var callerUserId, out var errorResult))
112125
return errorResult!;
113126

127+
// Enforce MFA for sensitive actions when policy requires it
128+
if (await _mfaService.IsMfaRequiredForSensitiveActionAsync(callerUserId))
129+
{
130+
if (string.IsNullOrWhiteSpace(request.MfaCode))
131+
return StatusCode(StatusCodes.Status403Forbidden, new ApiErrorResponse(
132+
ErrorCodes.Forbidden, "MFA verification is required for this action"));
133+
134+
var mfaResult = await _mfaService.VerifyCodeAsync(callerUserId, request.MfaCode);
135+
if (!mfaResult.IsSuccess)
136+
return StatusCode(StatusCodes.Status403Forbidden, new ApiErrorResponse(
137+
ErrorCodes.AuthenticationFailed, "Invalid MFA verification code"));
138+
}
139+
114140
var result = await _authService.ChangePasswordAsync(callerUserId, request.CurrentPassword, request.NewPassword);
115141
return result.IsSuccess ? NoContent() : result.ToErrorActionResult();
116142
}
@@ -447,17 +473,166 @@ public async Task<IActionResult> GetLinkedAccounts()
447473
}
448474

449475
/// <summary>
450-
/// Returns whether GitHub OAuth login is available on this instance.
476+
/// Returns available authentication providers on this instance.
451477
/// </summary>
452478
[HttpGet("providers")]
453479
public IActionResult GetProviders()
454480
{
481+
var oidcProviders = _oidcSettings.ConfiguredProviders
482+
.Select(p => new OidcProviderInfoDto(p.Name, p.DisplayName))
483+
.ToList();
484+
455485
return Ok(new
456486
{
457-
GitHub = _gitHubOAuthSettings.IsConfigured
487+
GitHub = _gitHubOAuthSettings.IsConfigured,
488+
Oidc = oidcProviders
458489
});
459490
}
460491

492+
/// <summary>
493+
/// Initiates OIDC login flow for a named provider. Only available when the provider is configured.
494+
/// </summary>
495+
[HttpGet("oidc/{providerName}/login")]
496+
[EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)]
497+
public IActionResult OidcLogin(string providerName, [FromQuery] string? returnUrl = null)
498+
{
499+
var provider = _oidcSettings.ConfiguredProviders
500+
.FirstOrDefault(p => string.Equals(p.Name, providerName, StringComparison.OrdinalIgnoreCase));
501+
502+
if (provider == null)
503+
return NotFound(new ApiErrorResponse(ErrorCodes.NotFound, $"OIDC provider '{providerName}' is not configured"));
504+
505+
if (!string.IsNullOrWhiteSpace(returnUrl) && !Url.IsLocalUrl(returnUrl))
506+
return BadRequest(new ApiErrorResponse(ErrorCodes.ValidationError, "Invalid return URL"));
507+
508+
var schemeName = $"Oidc_{provider.Name}";
509+
var properties = new AuthenticationProperties
510+
{
511+
RedirectUri = Url.Action(nameof(OidcCallback), new { providerName = provider.Name, returnUrl }),
512+
Items = { { "LoginProvider", provider.Name } }
513+
};
514+
515+
return Challenge(properties, schemeName);
516+
}
517+
518+
/// <summary>
519+
/// Handles the OIDC callback, creates/links the user, and redirects with a short-lived code.
520+
/// </summary>
521+
[HttpGet("oidc/{providerName}/callback")]
522+
[EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)]
523+
public async Task<IActionResult> OidcCallback(string providerName, [FromQuery] string? returnUrl = null)
524+
{
525+
var provider = _oidcSettings.ConfiguredProviders
526+
.FirstOrDefault(p => string.Equals(p.Name, providerName, StringComparison.OrdinalIgnoreCase));
527+
528+
if (provider == null)
529+
return NotFound(new ApiErrorResponse(ErrorCodes.NotFound, $"OIDC provider '{providerName}' is not configured"));
530+
531+
var schemeName = $"Oidc_{provider.Name}";
532+
var authenticateResult = await HttpContext.AuthenticateAsync(schemeName);
533+
if (!authenticateResult.Succeeded || authenticateResult.Principal == null)
534+
{
535+
return Unauthorized(new ApiErrorResponse(
536+
ErrorCodes.AuthenticationFailed,
537+
$"OIDC authentication with '{provider.DisplayName}' failed"));
538+
}
539+
540+
var claims = authenticateResult.Principal.Claims.ToList();
541+
var providerUserId = claims.FirstOrDefault(c => c.Type == System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
542+
var username = claims.FirstOrDefault(c => c.Type == System.Security.Claims.ClaimTypes.Name)?.Value
543+
?? claims.FirstOrDefault(c => c.Type == "preferred_username")?.Value;
544+
var email = claims.FirstOrDefault(c => c.Type == System.Security.Claims.ClaimTypes.Email)?.Value;
545+
var displayName = claims.FirstOrDefault(c => c.Type == "name")?.Value;
546+
547+
if (string.IsNullOrWhiteSpace(providerUserId))
548+
{
549+
return Unauthorized(new ApiErrorResponse(
550+
ErrorCodes.AuthenticationFailed,
551+
$"OIDC provider '{provider.DisplayName}' did not return a user identifier"));
552+
}
553+
554+
if (string.IsNullOrWhiteSpace(email))
555+
email = $"{provider.Name.ToLowerInvariant()}-{providerUserId}@external.taskdeck.local";
556+
557+
if (string.IsNullOrWhiteSpace(username))
558+
username = $"{provider.Name.ToLowerInvariant()}-user-{providerUserId}";
559+
560+
var dto = new ExternalLoginDto(
561+
Provider: $"oidc_{provider.Name}",
562+
ProviderUserId: providerUserId,
563+
Username: username,
564+
Email: email,
565+
DisplayName: displayName,
566+
AvatarUrl: null);
567+
568+
var result = await _authService.ExternalLoginAsync(dto);
569+
570+
if (!result.IsSuccess)
571+
return result.ToErrorActionResult();
572+
573+
// Sign out the temporary cookie used during the OIDC handshake
574+
await HttpContext.SignOutAsync(AuthenticationRegistration.ExternalAuthenticationScheme);
575+
576+
// Store only the user ID in the auth code -- JWT is re-issued at exchange time.
577+
var code = GenerateAuthCode();
578+
var authCode = new OAuthAuthCode(
579+
code: code,
580+
userId: result.Value.User.Id,
581+
token: "placeholder", // Not stored; JWT re-issued at exchange
582+
expiresAt: DateTimeOffset.UtcNow.AddSeconds(60));
583+
584+
await _unitOfWork.OAuthAuthCodes.AddAsync(authCode);
585+
await _unitOfWork.SaveChangesAsync();
586+
587+
// Best-effort cleanup of expired/consumed codes
588+
await CleanupExpiredCodesAsync();
589+
590+
var safeReturnUrl = !string.IsNullOrWhiteSpace(returnUrl) && Url.IsLocalUrl(returnUrl)
591+
? returnUrl
592+
: "/";
593+
594+
var separator = safeReturnUrl.Contains('?') ? "&" : "?";
595+
return Redirect($"{safeReturnUrl}{separator}oauth_code={Uri.EscapeDataString(code)}&oauth_provider=oidc");
596+
}
597+
598+
/// <summary>
599+
/// Exchanges a short-lived OIDC authorization code for a JWT token.
600+
/// Reuses the same database-backed code store as GitHub OAuth.
601+
/// </summary>
602+
[HttpPost("oidc/exchange")]
603+
[EnableRateLimiting(RateLimitingPolicyNames.AuthPerIp)]
604+
public async Task<IActionResult> OidcExchangeCode([FromBody] ExchangeCodeRequest request)
605+
{
606+
const string genericError = "Invalid or expired code";
607+
608+
if (string.IsNullOrWhiteSpace(request.Code))
609+
return BadRequest(new ApiErrorResponse(ErrorCodes.ValidationError, "Code is required"));
610+
611+
var authCode = await _unitOfWork.OAuthAuthCodes.GetByCodeAsync(request.Code);
612+
if (authCode == null || authCode.IsLinkingCode || authCode.IsExpired || authCode.IsConsumed)
613+
return Unauthorized(new ApiErrorResponse(ErrorCodes.AuthenticationFailed, genericError));
614+
615+
var consumed = await _unitOfWork.OAuthAuthCodes.TryConsumeAtomicAsync(request.Code);
616+
if (!consumed)
617+
return Unauthorized(new ApiErrorResponse(ErrorCodes.AuthenticationFailed, genericError));
618+
619+
var user = await _unitOfWork.Users.GetByIdAsync(authCode.UserId);
620+
if (user == null)
621+
return Unauthorized(new ApiErrorResponse(ErrorCodes.AuthenticationFailed, genericError));
622+
623+
var userDto = new UserDto(
624+
user.Id,
625+
user.Username,
626+
user.Email,
627+
user.DefaultRole,
628+
user.IsActive,
629+
user.CreatedAt,
630+
user.UpdatedAt);
631+
632+
var freshToken = _authService.GenerateJwtToken(user);
633+
return Ok(new AuthResultDto(freshToken, userDto));
634+
}
635+
461636
private static string GenerateAuthCode()
462637
{
463638
var bytes = RandomNumberGenerator.GetBytes(32);

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

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
using System.Security.Claims;
22
using System.Text;
33
using Microsoft.AspNetCore.Authentication;
4+
using Microsoft.AspNetCore.Authentication.Cookies;
45
using Microsoft.AspNetCore.Authentication.JwtBearer;
56
using Microsoft.AspNetCore.Authentication.OAuth.Claims;
7+
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
8+
using Microsoft.AspNetCore.Http;
69
using Microsoft.IdentityModel.Tokens;
710
using Taskdeck.Api.Contracts;
811
using Taskdeck.Application.Services;
@@ -13,10 +16,16 @@ namespace Taskdeck.Api.Extensions;
1316

1417
public static class AuthenticationRegistration
1518
{
19+
/// <summary>
20+
/// Cookie scheme used for temporary external auth state (OAuth/OIDC handshake).
21+
/// </summary>
22+
public const string ExternalAuthenticationScheme = "External";
23+
1624
public static IServiceCollection AddTaskdeckAuthentication(
1725
this IServiceCollection services,
1826
JwtSettings jwtSettings,
19-
GitHubOAuthSettings? gitHubOAuthSettings = null)
27+
GitHubOAuthSettings? gitHubOAuthSettings = null,
28+
OidcSettings? oidcSettings = null)
2029
{
2130
if (string.IsNullOrWhiteSpace(jwtSettings.SecretKey) ||
2231
jwtSettings.SecretKey.Length < 32 ||
@@ -26,7 +35,21 @@ public static IServiceCollection AddTaskdeckAuthentication(
2635
return services;
2736
}
2837

29-
var authBuilder = services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
38+
var authBuilder = services.AddAuthentication(options =>
39+
{
40+
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
41+
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
42+
options.DefaultSignInScheme = ExternalAuthenticationScheme;
43+
})
44+
.AddCookie(ExternalAuthenticationScheme, options =>
45+
{
46+
options.Cookie.Name = ".Taskdeck.ExternalAuth";
47+
options.Cookie.HttpOnly = true;
48+
options.Cookie.SameSite = SameSiteMode.Lax;
49+
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
50+
options.ExpireTimeSpan = TimeSpan.FromMinutes(5);
51+
options.SlidingExpiration = false;
52+
})
3053
.AddJwtBearer(options =>
3154
{
3255
options.TokenValidationParameters = new TokenValidationParameters
@@ -90,6 +113,7 @@ await context.Response.WriteAsJsonAsync(new ApiErrorResponse(
90113
{
91114
authBuilder.AddOAuth("GitHub", options =>
92115
{
116+
options.SignInScheme = ExternalAuthenticationScheme;
93117
options.ClientId = gitHubOAuthSettings.ClientId;
94118
options.ClientSecret = gitHubOAuthSettings.ClientSecret;
95119
options.AuthorizationEndpoint = "https://github.com/login/oauth/authorize";
@@ -118,6 +142,42 @@ await context.Response.WriteAsJsonAsync(new ApiErrorResponse(
118142
});
119143
}
120144

145+
// Environment-gated: add OIDC providers when configured
146+
if (oidcSettings != null)
147+
{
148+
foreach (var provider in oidcSettings.ConfiguredProviders)
149+
{
150+
var schemeName = $"Oidc_{provider.Name}";
151+
var callbackPath = !string.IsNullOrWhiteSpace(provider.CallbackPath)
152+
? provider.CallbackPath
153+
: $"/api/auth/oidc/{provider.Name.ToLowerInvariant()}/oauth-redirect";
154+
155+
authBuilder.AddOpenIdConnect(schemeName, provider.DisplayName, options =>
156+
{
157+
options.SignInScheme = ExternalAuthenticationScheme;
158+
options.Authority = provider.Authority;
159+
options.ClientId = provider.ClientId;
160+
options.ClientSecret = provider.ClientSecret;
161+
options.CallbackPath = callbackPath;
162+
options.ResponseType = "code";
163+
options.SaveTokens = false;
164+
options.GetClaimsFromUserInfoEndpoint = true;
165+
166+
options.Scope.Clear();
167+
foreach (var scope in provider.Scopes)
168+
{
169+
options.Scope.Add(scope);
170+
}
171+
172+
// Map standard OIDC claims to ClaimTypes
173+
options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "sub");
174+
options.ClaimActions.MapJsonKey(ClaimTypes.Name, "preferred_username");
175+
options.ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
176+
options.ClaimActions.MapJsonKey("name", "name");
177+
});
178+
}
179+
}
180+
121181
return services;
122182
}
123183

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Taskdeck.Api.RateLimiting;
77
using Taskdeck.Application.Services;
88
using Taskdeck.Domain.Exceptions;
9+
using Taskdeck.Infrastructure.Mcp;
910

1011
namespace Taskdeck.Api.Extensions;
1112

@@ -106,11 +107,21 @@ private static RateLimitPartition<string> BuildFixedWindowPartition(string parti
106107

107108
private static string ResolveUserOrClientIdentifier(HttpContext context)
108109
{
110+
// Check JWT claims first (REST API path).
109111
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier)
110112
?? context.User.FindFirstValue("sub");
111-
return !string.IsNullOrWhiteSpace(userId)
112-
? userId
113-
: ResolveClientAddress(context);
113+
if (!string.IsNullOrWhiteSpace(userId))
114+
return userId;
115+
116+
// Check API key user ID from HttpContext.Items (MCP HTTP path).
117+
// ApiKeyMiddleware sets this after validating the Bearer token.
118+
if (context.Items.TryGetValue(HttpUserContextProvider.UserIdItemKey, out var apiKeyUserId)
119+
&& apiKeyUserId is Guid apiKeyGuid)
120+
{
121+
return apiKeyGuid.ToString();
122+
}
123+
124+
return ResolveClientAddress(context);
114125
}
115126

116127
private static string ResolveClientAddress(HttpContext context)

0 commit comments

Comments
 (0)