From 3081d99355e982c861432b017d91daafe39b85b8 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 12 Jul 2026 00:12:23 +0200 Subject: [PATCH] feat(auth): Microsoft OAuth sign-in/link + self-serve signup via Google/Microsoft 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. --- .../AuthAndSystemControllerTests.cs | 76 ++++++- .../ClientErrorsControllerTests.cs | 2 +- .../MicrosoftTokenValidatorTests.cs | 77 ++++++++ JobTrackerApi/Controllers/AuthController.cs | 183 ++++++++++++++++- JobTrackerApi/Program.cs | 29 ++- .../Services/MicrosoftTokenValidator.cs | 100 ++++++++++ .../StartupInitializationExtensions.cs | 14 +- JobTrackerApi/appsettings.Development.json | 3 +- Models/ApplicationUser.cs | 3 + job-tracker-ui/package-lock.json | 22 +++ job-tracker-ui/package.json | 1 + .../src/components/MicrosoftAuthCard.tsx | 185 ++++++++++++++++++ job-tracker-ui/src/i18n/translations.ts | 42 ++++ job-tracker-ui/src/pages/LoginPage.tsx | 4 + job-tracker-ui/src/pages/ProfilePage.tsx | 2 + job-tracker-ui/src/setupTests.ts | 3 +- 16 files changed, 729 insertions(+), 17 deletions(-) create mode 100644 JobTrackerApi.Tests/MicrosoftTokenValidatorTests.cs create mode 100644 JobTrackerApi/Services/MicrosoftTokenValidator.cs create mode 100644 job-tracker-ui/src/components/MicrosoftAuthCard.tsx diff --git a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs index f28b553..2471710 100644 --- a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs +++ b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs @@ -25,7 +25,7 @@ public sealed class AuthAndSystemControllerTests userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); - var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance); + var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance); var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(" new@example.com ", " newuser ", " Ada ", " Lovelace ", " Ada L. ", null, null)); @@ -50,7 +50,7 @@ public sealed class AuthAndSystemControllerTests .Setup(x => x.SendAsync(user.Email!, It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("SMTP unavailable")); - var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), emailSender.Object, Mock.Of(), NullLogger.Instance) + var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), emailSender.Object, Mock.Of(), Mock.Of(), NullLogger.Instance) { ControllerContext = new ControllerContext { @@ -91,7 +91,7 @@ public sealed class AuthAndSystemControllerTests .Setup(x => x.ValidateAsync("google-token", It.IsAny())) .ReturnsAsync(new GoogleTokenPrincipal("google-subject", "dj@cesnimda.co.uk", true, "Dan", "Jones", "Dan Jones")); - var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of(), googleValidator.Object, NullLogger.Instance) + var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of(), googleValidator.Object, Mock.Of(), NullLogger.Instance) { ControllerContext = new ControllerContext { @@ -110,6 +110,76 @@ public sealed class AuthAndSystemControllerTests Assert.NotNull(user.GoogleLinkedAt); } + [Fact] + public async Task Exchange_microsoft_token_creates_new_user_when_registration_allowed() + { + var userManager = CreateUserManager(); + userManager.Setup(x => x.Users).Returns(new TestAsyncEnumerable(new List())); + userManager.Setup(x => x.FindByEmailAsync("new.hire@example.com")).ReturnsAsync((ApplicationUser?)null); + ApplicationUser? created = null; + userManager + .Setup(x => x.CreateAsync(It.IsAny())) + .Callback(u => created = u) + .ReturnsAsync(IdentityResult.Success); + userManager.Setup(x => x.UpdateAsync(It.IsAny())).ReturnsAsync(IdentityResult.Success); + + var tokenService = new Mock(); + tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); + + var microsoftValidator = new Mock(); + microsoftValidator + .Setup(x => x.ValidateAsync("microsoft-token", It.IsAny())) + .ReturnsAsync(new MicrosoftTokenPrincipal("ms-subject", "new.hire@example.com", true, "New", "Hire", "New Hire")); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["Auth:AllowRegistration"] = "true" }) + .Build(); + + var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of(), Mock.Of(), microsoftValidator.Object, NullLogger.Instance) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; + + var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var payload = Assert.IsType(ok.Value); + Assert.True(payload.Authenticated); + Assert.Equal("microsoft", payload.Provider); + Assert.NotNull(created); + Assert.Equal("new.hire@example.com", created!.Email); + Assert.Equal("ms-subject", created.MicrosoftSubject); + } + + [Fact] + public async Task Exchange_microsoft_token_rejects_unmatched_account_when_registration_disabled() + { + var userManager = CreateUserManager(); + userManager.Setup(x => x.Users).Returns(new TestAsyncEnumerable(new List())); + userManager.Setup(x => x.FindByEmailAsync("nobody@example.com")).ReturnsAsync((ApplicationUser?)null); + + var microsoftValidator = new Mock(); + microsoftValidator + .Setup(x => x.ValidateAsync("microsoft-token", It.IsAny())) + .ReturnsAsync(new MicrosoftTokenPrincipal("ms-subject", "nobody@example.com", true, null, null, null)); + + var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), microsoftValidator.Object, NullLogger.Instance) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; + + var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None); + + Assert.IsType(result.Result); + userManager.Verify(x => x.CreateAsync(It.IsAny()), Times.Never); + } + [Fact] public void Me_result_includes_google_link_details_for_local_users() { diff --git a/JobTrackerApi.Tests/ClientErrorsControllerTests.cs b/JobTrackerApi.Tests/ClientErrorsControllerTests.cs index eecfa6d..1fef5df 100644 --- a/JobTrackerApi.Tests/ClientErrorsControllerTests.cs +++ b/JobTrackerApi.Tests/ClientErrorsControllerTests.cs @@ -51,7 +51,7 @@ public sealed class ClientErrorsControllerTests var userManager = TestHostFactory.CreateUserManager(); userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); - var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of>()) + var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of>()) { ControllerContext = new ControllerContext { diff --git a/JobTrackerApi.Tests/MicrosoftTokenValidatorTests.cs b/JobTrackerApi.Tests/MicrosoftTokenValidatorTests.cs new file mode 100644 index 0000000..e257a9d --- /dev/null +++ b/JobTrackerApi.Tests/MicrosoftTokenValidatorTests.cs @@ -0,0 +1,77 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using JobTrackerApi.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class MicrosoftTokenValidatorTests +{ + private static (IConfiguration Config, Mock> ConfigManager, SymmetricSecurityKey Key) BuildHarness() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["Auth:MicrosoftClientId"] = "client-123" }) + .Build(); + + var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("super-secret-signing-key-super-secret")); + var oidc = new OpenIdConnectConfiguration(); + oidc.SigningKeys.Add(signingKey); + + var configManager = new Mock>(); + configManager.Setup(x => x.GetConfigurationAsync(It.IsAny())).ReturnsAsync(oidc); + + return (config, configManager, signingKey); + } + + [Fact] + public async Task ValidateAsync_accepts_tenant_scoped_issuer_and_maps_oid_to_subject() + { + var (config, configManager, signingKey) = BuildHarness(); + + var token = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( + issuer: "https://login.microsoftonline.com/9f2c1e3a-tenant/v2.0", + audience: "client-123", + claims: new[] + { + new Claim("oid", "ms-subject-1"), + new Claim("email", "demo@example.com"), + new Claim("given_name", "Demo"), + new Claim("family_name", "User"), + new Claim("name", "Demo User"), + }, + expires: DateTime.UtcNow.AddMinutes(10), + signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256))); + + var validator = new MicrosoftTokenValidator(config, configManager.Object); + var result = await validator.ValidateAsync(token); + + Assert.Equal("ms-subject-1", result.Subject); + Assert.Equal("demo@example.com", result.Email); + Assert.True(result.EmailVerified); + Assert.Equal("Demo", result.GivenName); + Assert.Equal("User", result.FamilyName); + } + + [Fact] + public async Task ValidateAsync_rejects_non_microsoft_issuer() + { + var (config, configManager, signingKey) = BuildHarness(); + + var token = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( + issuer: "https://evil.example.com/v2.0", + audience: "client-123", + claims: new[] { new Claim("oid", "ms-subject-1") }, + expires: DateTime.UtcNow.AddMinutes(10), + signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256))); + + var validator = new MicrosoftTokenValidator(config, configManager.Object); + + await Assert.ThrowsAsync(() => validator.ValidateAsync(token)); + } +} diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index 52c02c4..a83d1f4 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -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 _logger; - public AuthController(IConfiguration cfg, UserManager users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, ILogger logger) + public AuthController(IConfiguration cfg, UserManager users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger 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 Roles, - GoogleLinkDto? GoogleLink); + GoogleLinkDto? GoogleLink, + MicrosoftLinkDto? MicrosoftLink); private const int MaxAvatarBytes = 1_000_000; private static readonly HashSet 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> 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(), - 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> 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 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)); } } diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index eb2ae39..5f73116 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -162,6 +162,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -209,6 +210,7 @@ builder.Services.AddScoped(); var requireAuth = builder.Configuration.GetValue("Auth:Require", false); var googleClientId = (builder.Configuration["Auth:GoogleClientId"] ?? "").Trim(); +var microsoftClientId = (builder.Configuration["Auth:MicrosoftClientId"] ?? "").Trim(); var jwtKey = (builder.Configuration["Auth:JwtKey"] ?? "").Trim(); var ephemeralJwtKey = false; @@ -234,7 +236,7 @@ builder.Services.AddAuthentication(options => { options.ForwardDefaultSelector = ctx => { - if (string.IsNullOrWhiteSpace(googleClientId)) + if (string.IsNullOrWhiteSpace(googleClientId) && string.IsNullOrWhiteSpace(microsoftClientId)) return "local"; var auth = ctx.Request.Headers.Authorization.ToString(); @@ -250,9 +252,11 @@ builder.Services.AddAuthentication(options => { var jwt = handler.ReadJwtToken(token); var iss = jwt.Issuer ?? ""; - return iss is "accounts.google.com" or "https://accounts.google.com" - ? "google" - : "local"; + if (!string.IsNullOrWhiteSpace(googleClientId) && iss is "accounts.google.com" or "https://accounts.google.com") + return "google"; + if (!string.IsNullOrWhiteSpace(microsoftClientId) && iss.StartsWith("https://login.microsoftonline.com/", StringComparison.OrdinalIgnoreCase)) + return "microsoft"; + return "local"; } catch { @@ -322,6 +326,23 @@ if (!string.IsNullOrWhiteSpace(googleClientId)) }); } +if (!string.IsNullOrWhiteSpace(microsoftClientId)) +{ + builder.Services.AddAuthentication().AddJwtBearer("microsoft", options => + { + // Validate Microsoft (Entra ID / personal account) ID tokens as bearer tokens. + // "common" authority + ValidateIssuer=false: multi-tenant issuer varies per tenant id. + options.Authority = "https://login.microsoftonline.com/common/v2.0"; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = true, + ValidAudience = microsoftClientId, + ValidateLifetime = true, + }; + }); +} + builder.Services.AddAuthorization(options => { if (requireAuth) diff --git a/JobTrackerApi/Services/MicrosoftTokenValidator.cs b/JobTrackerApi/Services/MicrosoftTokenValidator.cs new file mode 100644 index 0000000..3fdc4a3 --- /dev/null +++ b/JobTrackerApi/Services/MicrosoftTokenValidator.cs @@ -0,0 +1,100 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; + +namespace JobTrackerApi.Services; + +public sealed record MicrosoftTokenPrincipal(string Subject, string? Email, bool EmailVerified, string? GivenName, string? FamilyName, string? Name); + +public interface IMicrosoftTokenValidator +{ + Task ValidateAsync(string idToken, CancellationToken cancellationToken = default); +} + +public sealed class MicrosoftTokenValidator : IMicrosoftTokenValidator +{ + private readonly IConfiguration _cfg; + private readonly IConfigurationManager _configManager; + + public MicrosoftTokenValidator(IConfiguration cfg) + { + _cfg = cfg; + // "common" endpoint: accepts both personal Microsoft accounts and work/school (Entra ID) tenants. + _configManager = new ConfigurationManager( + "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration", + new OpenIdConnectConfigurationRetriever()); + } + + public MicrosoftTokenValidator(IConfiguration cfg, IConfigurationManager configManager) + { + _cfg = cfg; + _configManager = configManager; + } + + public async Task ValidateAsync(string idToken, CancellationToken cancellationToken = default) + { + var audience = (_cfg["Auth:MicrosoftClientId"] ?? "").Trim(); + if (string.IsNullOrWhiteSpace(audience)) + { + throw new InvalidOperationException("Microsoft sign-in is not configured."); + } + + var config = await _configManager.GetConfigurationAsync(cancellationToken); + var handler = new JwtSecurityTokenHandler + { + // The handler's default inbound claim map rewrites "oid"/"tid" to long Microsoft + // schema URIs (an AAD-specific quirk not shared by Google's OIDC claims) -- keep + // claim names as issued so FindFirst("oid") below actually matches. + MapInboundClaims = false, + }; + // ponytail: multi-tenant "common" app -- each tenant's issuer embeds its own tenant id + // (https://login.microsoftonline.com/{tenantId}/v2.0), so issuer is checked by shape below + // rather than pinned to one value. Signature/audience/lifetime are still fully validated. + var principal = handler.ValidateToken(idToken, new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = true, + ValidAudience = audience, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKeys = config.SigningKeys, + ClockSkew = TimeSpan.FromMinutes(2), + }, out var validatedToken); + + var issuer = (validatedToken as JwtSecurityToken)?.Issuer ?? principal.FindFirst("iss")?.Value ?? ""; + if (!IsMicrosoftIssuer(issuer)) + { + throw new InvalidOperationException("Microsoft token has an unexpected issuer."); + } + + var subject = principal.FindFirst("oid")?.Value?.Trim() + ?? principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value?.Trim() + ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim(); + if (string.IsNullOrWhiteSpace(subject)) + { + throw new InvalidOperationException("Microsoft token is missing a subject."); + } + + var email = principal.FindFirst("email")?.Value?.Trim() + ?? principal.FindFirst(ClaimTypes.Email)?.Value?.Trim() + ?? principal.FindFirst("preferred_username")?.Value?.Trim(); + + return new MicrosoftTokenPrincipal( + Subject: subject, + Email: email, + // Microsoft ID tokens don't carry an email_verified claim; presence of an email claim + // from a signature-validated token is treated as verified, same trust level Microsoft's + // own APIs give it. + EmailVerified: !string.IsNullOrWhiteSpace(email), + GivenName: principal.FindFirst("given_name")?.Value?.Trim(), + FamilyName: principal.FindFirst("family_name")?.Value?.Trim(), + Name: principal.FindFirst("name")?.Value?.Trim() ?? principal.Identity?.Name?.Trim() + ); + } + + private static bool IsMicrosoftIssuer(string issuer) + => issuer.StartsWith("https://login.microsoftonline.com/", StringComparison.OrdinalIgnoreCase) + && issuer.EndsWith("/v2.0", StringComparison.OrdinalIgnoreCase); +} diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index b296a35..c3cf867 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -241,6 +241,9 @@ public static class StartupInitializationExtensions `GoogleSubject` longtext NULL, `GoogleEmail` longtext NULL, `GoogleLinkedAt` datetime(6) NULL, + `MicrosoftSubject` longtext NULL, + `MicrosoftEmail` longtext NULL, + `MicrosoftLinkedAt` datetime(6) NULL, PRIMARY KEY (`Id`) ) CHARACTER SET=utf8mb4; @@ -353,7 +356,10 @@ public static class StartupInitializationExtensions "AvatarImageDataUrl" TEXT NULL, "GoogleSubject" TEXT NULL, "GoogleEmail" TEXT NULL, - "GoogleLinkedAt" TEXT NULL + "GoogleLinkedAt" TEXT NULL, + "MicrosoftSubject" TEXT NULL, + "MicrosoftEmail" TEXT NULL, + "MicrosoftLinkedAt" TEXT NULL ); """); @@ -431,6 +437,9 @@ public static class StartupInitializationExtensions EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "MicrosoftSubject", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftSubject TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "MicrosoftEmail", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftEmail TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "MicrosoftLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftLinkedAt TEXT NULL;"); static void EnsureUserRuleSettingsTable(DbConnection c) { @@ -757,6 +766,9 @@ public static class StartupInitializationExtensions EnsureMySqlColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleSubject` longtext NULL;"); EnsureMySqlColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleEmail` longtext NULL;"); EnsureMySqlColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleLinkedAt` datetime NULL;"); + EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftSubject` longtext NULL;"); + EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftEmail` longtext NULL;"); + EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftLinkedAt` datetime NULL;"); if (!HasMySqlTable(conn, "RuleSettings")) { diff --git a/JobTrackerApi/appsettings.Development.json b/JobTrackerApi/appsettings.Development.json index 0218ac6..95ab79f 100644 --- a/JobTrackerApi/appsettings.Development.json +++ b/JobTrackerApi/appsettings.Development.json @@ -26,7 +26,8 @@ "JwtExpiresMinutes": 720, "AdminEmail": "admin@example.com", "AdminPassword": "CHANGE_ME_STRONG_DEV_PASSWORD", - "GoogleClientId": "CHANGE_ME_GOOGLE_CLIENT_ID" + "GoogleClientId": "CHANGE_ME_GOOGLE_CLIENT_ID", + "MicrosoftClientId": "CHANGE_ME_MICROSOFT_CLIENT_ID" }, "App": { "PublicBaseUrl": "https://jobs.cesnimda.uk" diff --git a/Models/ApplicationUser.cs b/Models/ApplicationUser.cs index 94e8c6b..6d835ef 100644 --- a/Models/ApplicationUser.cs +++ b/Models/ApplicationUser.cs @@ -16,4 +16,7 @@ public sealed class ApplicationUser : IdentityUser public string? GoogleSubject { get; set; } public string? GoogleEmail { get; set; } public DateTimeOffset? GoogleLinkedAt { get; set; } + public string? MicrosoftSubject { get; set; } + public string? MicrosoftEmail { get; set; } + public DateTimeOffset? MicrosoftLinkedAt { get; set; } } diff --git a/job-tracker-ui/package-lock.json b/job-tracker-ui/package-lock.json index 40c78e9..4ef55fe 100644 --- a/job-tracker-ui/package-lock.json +++ b/job-tracker-ui/package-lock.json @@ -8,6 +8,7 @@ "name": "job-tracker-ui", "version": "0.1.0", "dependencies": { + "@azure/msal-browser": "^5.17.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.9", @@ -52,6 +53,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@azure/msal-browser": { + "version": "5.17.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.17.0.tgz", + "integrity": "sha512-/yTnW2TCk9Mh+2b/NOaHAN+MryUNxzRTaJD/YtrqOA9bpBWfTXn/iyReRbaLrK/btBo3stEzLyEvuWp2NZ5DuA==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.11.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.11.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.11.1.tgz", + "integrity": "sha512-yPohvMwWLv1XnaWnIUyKUh8CvcVChCGqG/VluGwfGmaAfrZTNt5yQ+sIs462Sgw6+e2K83KGmMJ860p73ZSCrw==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", diff --git a/job-tracker-ui/package.json b/job-tracker-ui/package.json index 6b9b143..f980cfe 100644 --- a/job-tracker-ui/package.json +++ b/job-tracker-ui/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { + "@azure/msal-browser": "^5.17.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.9", diff --git a/job-tracker-ui/src/components/MicrosoftAuthCard.tsx b/job-tracker-ui/src/components/MicrosoftAuthCard.tsx new file mode 100644 index 0000000..8722ea3 --- /dev/null +++ b/job-tracker-ui/src/components/MicrosoftAuthCard.tsx @@ -0,0 +1,185 @@ +import React, { useEffect, useState } from "react"; + +import { Box, Button, Chip, Paper, Typography } from "@mui/material"; +import { PublicClientApplication } from "@azure/msal-browser"; + +import { api, getApiErrorMessage } from "../api"; +import { clearAuthClientState, getAuthPersistencePreference } from "../auth"; +import { useToast } from "../toast"; +import { useI18n } from "../i18n/I18nProvider"; + +type MeResponse = { + provider?: "local" | "google" | "microsoft" | "external"; + email?: string; + userName?: string; + displayName?: string; + firstName?: string; + lastName?: string; + microsoftLink?: { + linked: boolean; + email?: string | null; + linkedAt?: string | null; + } | null; +}; + +let msalInstance: PublicClientApplication | null = null; +function getMsalInstance(clientId: string): PublicClientApplication { + msalInstance ??= new PublicClientApplication({ + auth: { clientId, authority: "https://login.microsoftonline.com/common", redirectUri: window.location.origin }, + }); + return msalInstance; +} + +export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => void }) { + const { toast } = useToast(); + const { t } = useI18n(); + const [me, setMe] = useState(null); + const [working, setWorking] = useState(false); + + const clientId = (process.env.REACT_APP_MICROSOFT_CLIENT_ID || "").trim(); + const signedIn = Boolean(me?.provider); + const actionLabel = !signedIn + ? t("continueWithMicrosoft") + : me?.provider === "local" && !me?.microsoftLink?.linked + ? t("linkWithMicrosoft") + : t("signInWithMicrosoft"); + + async function refreshMe() { + try { + const res = await api.get("/auth/me"); + setMe(res.data); + } catch { + setMe(null); + } + } + + useEffect(() => { + void refreshMe(); + }, []); + + useEffect(() => { + const onAuthChanged = () => { void refreshMe(); }; + window.addEventListener("auth-changed", onAuthChanged); + return () => window.removeEventListener("auth-changed", onAuthChanged); + }, []); + + async function handleSignIn() { + if (!clientId) return; + setWorking(true); + try { + const msal = getMsalInstance(clientId); + await msal.initialize(); + const result = await msal.loginPopup({ scopes: ["openid", "profile", "email"] }); + const idToken = result.idToken; + if (!idToken) throw new Error(t("microsoftAuthFailed")); + + if (me?.provider === "local") { + const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/microsoft/link", { token: idToken, rememberMe: getAuthPersistencePreference() === "local" }); + toast(res.data?.email ? t("microsoftLinkedSuccessWithEmail", { email: res.data.email }) : t("microsoftLinkedSuccess"), "success"); + await refreshMe(); + } else { + await api.post("/auth/microsoft/exchange", { token: idToken, rememberMe: getAuthPersistencePreference() === "local" }); + window.dispatchEvent(new Event("auth-changed")); + toast(t("microsoftSignedIn"), "success"); + onSignedIn?.(); + } + } catch (e: any) { + toast(getApiErrorMessage(e, t("microsoftAuthFailed")), "error"); + } finally { + setWorking(false); + } + } + + const signedInName = me?.userName || me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email || ""; + + return ( + + + {t("microsoftAccountTitle")} + + + {!clientId && ( + + {t("microsoftSetupHint")} + + )} + + {clientId && ( + + + + {me?.microsoftLink?.linkedAt ? : null} + + + {!signedIn ? ( + + {t("microsoftSignInHint")} + + ) : me?.provider === "local" ? ( + + {me.microsoftLink?.linked + ? t("microsoftLinkedTo", { email: me.microsoftLink.email || t("microsoftLinkedToYourAccount") }) + : t("microsoftBindHint")} + + ) : ( + + {t("microsoftExchangeHint")} + + )} + + + + {actionLabel} + + + + + + {signedIn ? ( + + ) : null} + + {me?.provider === "local" && me.microsoftLink?.linked ? ( + + ) : null} + + + {signedIn && me?.email ? ( + + {t("signedInAs", { name: signedInName })} + + ) : null} + + )} + + ); +} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index fae2022..bc18b38 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -628,6 +628,26 @@ export const translations = { googleScriptLoadFailed: "Google auth script failed to load.", googleUnlinked: "Google account unlinked.", googleUnlinkFailed: "Failed to unlink Google account.", + microsoftAccountTitle: "Microsoft account", + microsoftSetupHint: "Set `REACT_APP_MICROSOFT_CLIENT_ID` in your UI environment to enable Microsoft sign-in and account linking.", + microsoftLinked: "Linked", + microsoftAvailableToLink: "Available to link", + microsoftLinkedDate: "Linked {date}", + microsoftSignInHint: "Sign in with a Microsoft account that has already been linked to your Jobbjakt user.", + continueWithMicrosoft: "Continue with Microsoft", + signInWithMicrosoft: "Sign in with Microsoft", + linkWithMicrosoft: "Link with Microsoft", + microsoftLinkedTo: "Linked to {email}.", + microsoftLinkedToYourAccount: "Linked to your Microsoft account.", + microsoftBindHint: "Bind a Microsoft account to this user so you can sign in with Microsoft and still keep your normal app roles and data.", + microsoftExchangeHint: "Exchange your Microsoft sign-in for a normal Jobbjakt session.", + microsoftSignedIn: "Signed in with Microsoft.", + microsoftLinkedSuccess: "Microsoft account linked.", + microsoftLinkedSuccessWithEmail: "Linked Microsoft account {email}.", + microsoftAuthFailed: "Microsoft authentication failed.", + microsoftUnlinked: "Microsoft account unlinked.", + microsoftUnlinkFailed: "Failed to unlink Microsoft account.", + unlinkMicrosoft: "Unlink Microsoft", signedOut: "Signed out.", signedInAs: "Signed in as {name}.", unlinkGoogle: "Unlink Google", @@ -663,6 +683,7 @@ export const translations = { authOptional: "Authentication is optional in this environment.", emailAndPassword: "Email & password", google: "Google", + microsoft: "Microsoft", createAccount: "Create account", signedIn: "Signed in.", rememberMe: "Remember me", @@ -1572,6 +1593,26 @@ export const translations = { googleScriptLoadFailed: "Kunne ikke laste Google-autentiseringsskriptet.", googleUnlinked: "Google-konto koblet fra.", googleUnlinkFailed: "Kunne ikke koble fra Google-kontoen.", + microsoftAccountTitle: "Microsoft-konto", + microsoftSetupHint: "Sett `REACT_APP_MICROSOFT_CLIENT_ID` i UI-miljøet ditt for å aktivere Microsoft-innlogging og kontokobling.", + microsoftLinked: "Koblet", + microsoftAvailableToLink: "Tilgjengelig for kobling", + microsoftLinkedDate: "Koblet {date}", + microsoftSignInHint: "Logg inn med en Microsoft-konto som allerede er koblet til Jobbjakt-brukeren din.", + continueWithMicrosoft: "Fortsett med Microsoft", + signInWithMicrosoft: "Logg inn med Microsoft", + linkWithMicrosoft: "Koble til med Microsoft", + microsoftLinkedTo: "Koblet til {email}.", + microsoftLinkedToYourAccount: "Koblet til Microsoft-kontoen din.", + microsoftBindHint: "Koble en Microsoft-konto til denne brukeren slik at du kan logge inn med Microsoft og fortsatt beholde vanlige approller og data.", + microsoftExchangeHint: "Bytt Microsoft-innloggingen din mot en vanlig Jobbjakt-økt.", + microsoftSignedIn: "Logget inn med Microsoft.", + microsoftLinkedSuccess: "Microsoft-konto koblet.", + microsoftLinkedSuccessWithEmail: "Koblet Microsoft-konto {email}.", + microsoftAuthFailed: "Microsoft-autentisering mislyktes.", + microsoftUnlinked: "Microsoft-konto koblet fra.", + microsoftUnlinkFailed: "Kunne ikke koble fra Microsoft-kontoen.", + unlinkMicrosoft: "Koble fra Microsoft", signedOut: "Logget ut.", signedInAs: "Logget inn som {name}.", unlinkGoogle: "Koble fra Google", @@ -1607,6 +1648,7 @@ export const translations = { authOptional: "Autentisering er valgfri i dette miljøet.", emailAndPassword: "E-post og passord", google: "Google", + microsoft: "Microsoft", createAccount: "Opprett konto", signedIn: "Logget inn.", rememberMe: "Husk meg", diff --git a/job-tracker-ui/src/pages/LoginPage.tsx b/job-tracker-ui/src/pages/LoginPage.tsx index 7ede905..ebab1a2 100644 --- a/job-tracker-ui/src/pages/LoginPage.tsx +++ b/job-tracker-ui/src/pages/LoginPage.tsx @@ -7,12 +7,14 @@ import { useLocation, useNavigate } from "react-router-dom"; import { api, getApiErrorMessage } from "../api"; import { getRememberMePref, setAuthPersistencePreference } from "../auth"; import GoogleAuthCard from "../components/GoogleAuthCard"; +import MicrosoftAuthCard from "../components/MicrosoftAuthCard"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; type AuthConfig = { requireAuth: boolean; googleEnabled: boolean; + microsoftEnabled: boolean; localEnabled: boolean; allowRegistration: boolean; }; @@ -81,6 +83,7 @@ export default function LoginPage() { setTab(v)} sx={{ mb: 2 }}> + {tab === 0 && ( @@ -123,6 +126,7 @@ export default function LoginPage() { )} {tab === 1 && { navigate(nextPath, { replace: true }); }} />} + {tab === 2 && { navigate(nextPath, { replace: true }); }} />} ); diff --git a/job-tracker-ui/src/pages/ProfilePage.tsx b/job-tracker-ui/src/pages/ProfilePage.tsx index 3b35b00..7ef673f 100644 --- a/job-tracker-ui/src/pages/ProfilePage.tsx +++ b/job-tracker-ui/src/pages/ProfilePage.tsx @@ -9,6 +9,7 @@ import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined"; import { api, getApiErrorMessage } from "../api"; import GoogleAuthCard from "../components/GoogleAuthCard"; +import MicrosoftAuthCard from "../components/MicrosoftAuthCard"; import CropImageDialog from "../components/CropImageDialog"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; @@ -562,6 +563,7 @@ export default function ProfilePage() { + diff --git a/job-tracker-ui/src/setupTests.ts b/job-tracker-ui/src/setupTests.ts index b21d22f..781cc90 100644 --- a/job-tracker-ui/src/setupTests.ts +++ b/job-tracker-ui/src/setupTests.ts @@ -29,12 +29,13 @@ jest.mock('./api', () => ({ })); jest.mock('./components/GoogleAuthCard', () => () => null); +jest.mock('./components/MicrosoftAuthCard', () => () => null); beforeEach(() => { const { api } = require('./api'); api.get.mockImplementation((url: string) => { if (url === '/auth/config') { - return Promise.resolve({ data: { requireAuth: false, googleEnabled: false, localEnabled: true, allowRegistration: false } }); + return Promise.resolve({ data: { requireAuth: false, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: false } }); } if (url === '/auth/me') { return Promise.resolve({ data: { roles: [], email: 'demo@example.com', userName: 'demo' } });