feat(auth): Microsoft OAuth sign-in/link + self-serve signup via Google/Microsoft
CI and Deploy / test (pull_request) Successful in 2m9s
CI and Deploy / deploy (pull_request) Has been skipped

Wave 7. Mirrors the existing Google ID-token-exchange pattern (Program.cs
smart-scheme dispatch, JWT bearer scheme, AuthController exchange/link/
unlink endpoints, ApplicationUser fields, reconciler columns) for
Microsoft Entra ID + personal accounts via the multi-tenant "common"
endpoint.

Google/Microsoft sign-in previously only worked for accounts already
linked to an existing local user -- there was no way to actually sign
up via OAuth. Both exchange endpoints now create a new user when no
match is found and Auth:AllowRegistration is true, same gate as
email/password registration.

Frontend: new MicrosoftAuthCard (MSAL popup flow -- Microsoft has no
vanilla-JS equivalent to Google's Identity Services script) wired into
the login page's provider tabs and the profile page's account-linking
section. REACT_APP_MICROSOFT_CLIENT_ID env var, Auth:MicrosoftClientId
config gate on the backend.
This commit is contained in:
cesnimda
2026-07-12 00:12:23 +02:00
parent fc62a659ef
commit 3081d99355
16 changed files with 729 additions and 17 deletions
+177 -6
View File
@@ -19,15 +19,17 @@ public sealed class AuthController : ControllerBase
private readonly ITokenService _tokens;
private readonly IAppEmailSender _email;
private readonly IGoogleTokenValidator _googleTokens;
private readonly IMicrosoftTokenValidator _microsoftTokens;
private readonly ILogger<AuthController> _logger;
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, ILogger<AuthController> logger)
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger)
{
_cfg = cfg;
_users = users;
_tokens = tokens;
_email = email;
_googleTokens = googleTokens;
_microsoftTokens = microsoftTokens;
_logger = logger;
}
@@ -37,12 +39,14 @@ public sealed class AuthController : ControllerBase
{
var requireAuth = _cfg.GetValue("Auth:Require", false);
var googleEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? string.Empty).Trim());
var microsoftEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:MicrosoftClientId"] ?? string.Empty).Trim());
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
return Ok(new
{
requireAuth,
googleEnabled,
microsoftEnabled,
localEnabled = true,
allowRegistration,
});
@@ -52,6 +56,7 @@ public sealed class AuthController : ControllerBase
public sealed record RegisterRequest(string Email, string Password, bool RememberMe = true);
public sealed record AuthSessionResult(bool Authenticated, string Provider);
public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
public sealed record MicrosoftLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
public sealed record MeResult(
string Provider,
string? Id,
@@ -64,7 +69,8 @@ public sealed class AuthController : ControllerBase
string? ProfileCvStructureJson,
string? AvatarImageDataUrl,
IList<string> Roles,
GoogleLinkDto? GoogleLink);
GoogleLinkDto? GoogleLink,
MicrosoftLinkDto? MicrosoftLink);
private const int MaxAvatarBytes = 1_000_000;
private static readonly HashSet<string> AllowedAvatarExtensions = new(StringComparer.OrdinalIgnoreCase)
{
@@ -72,6 +78,7 @@ public sealed class AuthController : ControllerBase
};
public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName, string? ProfileCvText, string? ProfileCvStructureJson);
public sealed record GoogleTokenRequest(string Token, bool RememberMe = true);
public sealed record MicrosoftTokenRequest(string Token, bool RememberMe = true);
[HttpPost("login")]
[AllowAnonymous]
@@ -155,7 +162,24 @@ public sealed class AuthController : ControllerBase
if (user is null)
{
return Unauthorized("This Google account is not linked to a Jobbjakt user yet.");
if (!google.EmailVerified || string.IsNullOrWhiteSpace(google.Email))
{
return Unauthorized("This Google account is not linked to a Jobbjakt user yet.");
}
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
if (!allowRegistration)
{
return Unauthorized("This Google account is not linked to a Jobbjakt user yet.");
}
user = new ApplicationUser { UserName = google.Email, Email = google.Email, EmailConfirmed = true };
var created = await _users.CreateAsync(user);
if (!created.Succeeded)
{
return BadRequest(string.Join("; ", created.Errors.Select(e => e.Description)));
}
_logger.LogInformation("Created new user via Google sign-up for {Email}", google.Email);
}
if (string.IsNullOrWhiteSpace(user.GoogleSubject) || !string.Equals(user.GoogleSubject, google.Subject, StringComparison.Ordinal))
@@ -173,6 +197,74 @@ public sealed class AuthController : ControllerBase
return Ok(new AuthSessionResult(true, "google"));
}
[HttpPost("microsoft/exchange")]
[AllowAnonymous]
[EnableRateLimiting("auth-login")]
public async Task<ActionResult<AuthSessionResult>> ExchangeMicrosoftToken([FromBody] MicrosoftTokenRequest request, CancellationToken cancellationToken)
{
var token = (request.Token ?? string.Empty).Trim();
if (token.Length == 0) return BadRequest("Microsoft token is required.");
MicrosoftTokenPrincipal microsoft;
try
{
microsoft = await _microsoftTokens.ValidateAsync(token, cancellationToken);
}
catch (Exception ex)
{
return Unauthorized(ex.Message);
}
var user = await _users.Users.FirstOrDefaultAsync(
x => x.MicrosoftSubject == microsoft.Subject || (!string.IsNullOrWhiteSpace(microsoft.Email) && x.MicrosoftEmail == microsoft.Email),
cancellationToken);
if (user is null && microsoft.EmailVerified && !string.IsNullOrWhiteSpace(microsoft.Email))
{
user = await _users.FindByEmailAsync(microsoft.Email);
if (user is not null)
{
_logger.LogInformation("Auto-linking Microsoft sign-in for existing local account {Email}", microsoft.Email);
}
}
if (user is null)
{
if (!microsoft.EmailVerified || string.IsNullOrWhiteSpace(microsoft.Email))
{
return Unauthorized("This Microsoft account is not linked to a Jobbjakt user yet.");
}
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
if (!allowRegistration)
{
return Unauthorized("This Microsoft account is not linked to a Jobbjakt user yet.");
}
user = new ApplicationUser { UserName = microsoft.Email, Email = microsoft.Email, EmailConfirmed = true };
var created = await _users.CreateAsync(user);
if (!created.Succeeded)
{
return BadRequest(string.Join("; ", created.Errors.Select(e => e.Description)));
}
_logger.LogInformation("Created new user via Microsoft sign-up for {Email}", microsoft.Email);
}
if (string.IsNullOrWhiteSpace(user.MicrosoftSubject) || !string.Equals(user.MicrosoftSubject, microsoft.Subject, StringComparison.Ordinal))
{
user.MicrosoftSubject = microsoft.Subject;
user.MicrosoftEmail = microsoft.Email;
user.MicrosoftLinkedAt ??= DateTimeOffset.UtcNow;
user.DisplayName ??= TrimOrNull(microsoft.Name);
user.FirstName ??= TrimOrNull(microsoft.GivenName);
user.LastName ??= TrimOrNull(microsoft.FamilyName);
await _users.UpdateAsync(user);
}
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken);
return Ok(new AuthSessionResult(true, "microsoft"));
}
[HttpPost("logout")]
public IActionResult Logout()
{
@@ -202,7 +294,11 @@ public sealed class AuthController : ControllerBase
var email = User.FindFirstValue(ClaimTypes.Email) ?? User.FindFirstValue("email");
var sub = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
var iss = User.FindFirstValue("iss") ?? string.Empty;
var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase) ? "google" : "external";
var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase)
? "google"
: iss.Contains("login.microsoftonline.com", StringComparison.OrdinalIgnoreCase)
? "microsoft"
: "external";
return Ok(new MeResult(
Provider: provider,
@@ -216,7 +312,8 @@ public sealed class AuthController : ControllerBase
ProfileCvStructureJson: null,
AvatarImageDataUrl: null,
Roles: Array.Empty<string>(),
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null));
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null,
MicrosoftLink: provider == "microsoft" ? new MicrosoftLinkDto(false, email, null) : null));
}
[HttpPut("profile")]
@@ -322,6 +419,76 @@ public sealed class AuthController : ControllerBase
return NoContent();
}
[HttpPost("microsoft/link")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<MicrosoftLinkDto>> LinkMicrosoft([FromBody] MicrosoftTokenRequest request, CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
var token = (request.Token ?? string.Empty).Trim();
if (token.Length == 0) return BadRequest("Microsoft token is required.");
MicrosoftTokenPrincipal microsoft;
try
{
microsoft = await _microsoftTokens.ValidateAsync(token, cancellationToken);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
var conflict = await _users.Users
.Where(x => x.Id != user.Id)
.FirstOrDefaultAsync(x => x.MicrosoftSubject == microsoft.Subject || (!string.IsNullOrWhiteSpace(microsoft.Email) && x.MicrosoftEmail == microsoft.Email), cancellationToken);
if (conflict is not null)
{
return Conflict("That Microsoft account is already linked to another Jobbjakt user.");
}
user.MicrosoftSubject = microsoft.Subject;
user.MicrosoftEmail = microsoft.Email;
user.MicrosoftLinkedAt = DateTimeOffset.UtcNow;
user.DisplayName ??= TrimOrNull(microsoft.Name);
user.FirstName ??= TrimOrNull(microsoft.GivenName);
user.LastName ??= TrimOrNull(microsoft.FamilyName);
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
return Ok(new MicrosoftLinkDto(true, user.MicrosoftEmail, user.MicrosoftLinkedAt));
}
[HttpDelete("microsoft/link")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> UnlinkMicrosoft()
{
var user = await _users.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
user.MicrosoftSubject = null;
user.MicrosoftEmail = null;
user.MicrosoftLinkedAt = null;
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
return NoContent();
}
[HttpPost("avatar")]
[Authorize(AuthenticationSchemes = "local")]
[RequestSizeLimit(MaxAvatarBytes)]
@@ -571,6 +738,10 @@ public sealed class AuthController : ControllerBase
GoogleLink: new GoogleLinkDto(
Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject),
Email: user.GoogleEmail,
LinkedAt: user.GoogleLinkedAt));
LinkedAt: user.GoogleLinkedAt),
MicrosoftLink: new MicrosoftLinkDto(
Linked: !string.IsNullOrWhiteSpace(user.MicrosoftSubject),
Email: user.MicrosoftEmail,
LinkedAt: user.MicrosoftLinkedAt));
}
}