1717
1818namespace Taskdeck . Api . Controllers ;
1919
20- public record ChangePasswordRequest ( string CurrentPassword , string NewPassword ) ;
20+ public record ChangePasswordRequest ( string CurrentPassword , string NewPassword , string ? MfaCode = null ) ;
2121public record ExchangeCodeRequest ( string Code ) ;
2222public 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 ) ;
0 commit comments