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.
This commit is contained in:
@@ -25,7 +25,7 @@ public sealed class AuthAndSystemControllerTests
|
|||||||
userManager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
|
userManager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
|
||||||
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||||
|
|
||||||
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), NullLogger<AuthController>.Instance);
|
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance);
|
||||||
|
|
||||||
var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(" new@example.com ", " newuser ", " Ada ", " Lovelace ", " Ada L. ", null, null));
|
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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
.Setup(x => x.SendAsync(user.Email!, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
.ThrowsAsync(new InvalidOperationException("SMTP unavailable"));
|
.ThrowsAsync(new InvalidOperationException("SMTP unavailable"));
|
||||||
|
|
||||||
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), emailSender.Object, Mock.Of<IGoogleTokenValidator>(), NullLogger<AuthController>.Instance)
|
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance)
|
||||||
{
|
{
|
||||||
ControllerContext = new ControllerContext
|
ControllerContext = new ControllerContext
|
||||||
{
|
{
|
||||||
@@ -91,7 +91,7 @@ public sealed class AuthAndSystemControllerTests
|
|||||||
.Setup(x => x.ValidateAsync("google-token", It.IsAny<CancellationToken>()))
|
.Setup(x => x.ValidateAsync("google-token", It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new GoogleTokenPrincipal("google-subject", "dj@cesnimda.co.uk", true, "Dan", "Jones", "Dan Jones"));
|
.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<IAppEmailSender>(), googleValidator.Object, NullLogger<AuthController>.Instance)
|
var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), googleValidator.Object, Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance)
|
||||||
{
|
{
|
||||||
ControllerContext = new ControllerContext
|
ControllerContext = new ControllerContext
|
||||||
{
|
{
|
||||||
@@ -110,6 +110,76 @@ public sealed class AuthAndSystemControllerTests
|
|||||||
Assert.NotNull(user.GoogleLinkedAt);
|
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<ApplicationUser>(new List<ApplicationUser>()));
|
||||||
|
userManager.Setup(x => x.FindByEmailAsync("new.hire@example.com")).ReturnsAsync((ApplicationUser?)null);
|
||||||
|
ApplicationUser? created = null;
|
||||||
|
userManager
|
||||||
|
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>()))
|
||||||
|
.Callback<ApplicationUser>(u => created = u)
|
||||||
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
|
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
|
||||||
|
|
||||||
|
var tokenService = new Mock<ITokenService>();
|
||||||
|
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
||||||
|
|
||||||
|
var microsoftValidator = new Mock<IMicrosoftTokenValidator>();
|
||||||
|
microsoftValidator
|
||||||
|
.Setup(x => x.ValidateAsync("microsoft-token", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new MicrosoftTokenPrincipal("ms-subject", "new.hire@example.com", true, "New", "Hire", "New Hire"));
|
||||||
|
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:AllowRegistration"] = "true" })
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance)
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
|
var payload = Assert.IsType<AuthController.AuthSessionResult>(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<ApplicationUser>(new List<ApplicationUser>()));
|
||||||
|
userManager.Setup(x => x.FindByEmailAsync("nobody@example.com")).ReturnsAsync((ApplicationUser?)null);
|
||||||
|
|
||||||
|
var microsoftValidator = new Mock<IMicrosoftTokenValidator>();
|
||||||
|
microsoftValidator
|
||||||
|
.Setup(x => x.ValidateAsync("microsoft-token", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new MicrosoftTokenPrincipal("ms-subject", "nobody@example.com", true, null, null, null));
|
||||||
|
|
||||||
|
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance)
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<UnauthorizedObjectResult>(result.Result);
|
||||||
|
userManager.Verify(x => x.CreateAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Me_result_includes_google_link_details_for_local_users()
|
public void Me_result_includes_google_link_details_for_local_users()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ public sealed class ClientErrorsControllerTests
|
|||||||
var userManager = TestHostFactory.CreateUserManager();
|
var userManager = TestHostFactory.CreateUserManager();
|
||||||
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||||
|
|
||||||
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<ILogger<AuthController>>())
|
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), Mock.Of<ILogger<AuthController>>())
|
||||||
{
|
{
|
||||||
ControllerContext = new ControllerContext
|
ControllerContext = new ControllerContext
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<IConfigurationManager<OpenIdConnectConfiguration>> ConfigManager, SymmetricSecurityKey Key) BuildHarness()
|
||||||
|
{
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?> { ["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<IConfigurationManager<OpenIdConnectConfiguration>>();
|
||||||
|
configManager.Setup(x => x.GetConfigurationAsync(It.IsAny<CancellationToken>())).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<InvalidOperationException>(() => validator.ValidateAsync(token));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,15 +19,17 @@ public sealed class AuthController : ControllerBase
|
|||||||
private readonly ITokenService _tokens;
|
private readonly ITokenService _tokens;
|
||||||
private readonly IAppEmailSender _email;
|
private readonly IAppEmailSender _email;
|
||||||
private readonly IGoogleTokenValidator _googleTokens;
|
private readonly IGoogleTokenValidator _googleTokens;
|
||||||
|
private readonly IMicrosoftTokenValidator _microsoftTokens;
|
||||||
private readonly ILogger<AuthController> _logger;
|
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;
|
_cfg = cfg;
|
||||||
_users = users;
|
_users = users;
|
||||||
_tokens = tokens;
|
_tokens = tokens;
|
||||||
_email = email;
|
_email = email;
|
||||||
_googleTokens = googleTokens;
|
_googleTokens = googleTokens;
|
||||||
|
_microsoftTokens = microsoftTokens;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,12 +39,14 @@ public sealed class AuthController : ControllerBase
|
|||||||
{
|
{
|
||||||
var requireAuth = _cfg.GetValue("Auth:Require", false);
|
var requireAuth = _cfg.GetValue("Auth:Require", false);
|
||||||
var googleEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? string.Empty).Trim());
|
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);
|
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
requireAuth,
|
requireAuth,
|
||||||
googleEnabled,
|
googleEnabled,
|
||||||
|
microsoftEnabled,
|
||||||
localEnabled = true,
|
localEnabled = true,
|
||||||
allowRegistration,
|
allowRegistration,
|
||||||
});
|
});
|
||||||
@@ -52,6 +56,7 @@ public sealed class AuthController : ControllerBase
|
|||||||
public sealed record RegisterRequest(string Email, string Password, bool RememberMe = true);
|
public sealed record RegisterRequest(string Email, string Password, bool RememberMe = true);
|
||||||
public sealed record AuthSessionResult(bool Authenticated, string Provider);
|
public sealed record AuthSessionResult(bool Authenticated, string Provider);
|
||||||
public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
|
public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
|
||||||
|
public sealed record MicrosoftLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
|
||||||
public sealed record MeResult(
|
public sealed record MeResult(
|
||||||
string Provider,
|
string Provider,
|
||||||
string? Id,
|
string? Id,
|
||||||
@@ -64,7 +69,8 @@ public sealed class AuthController : ControllerBase
|
|||||||
string? ProfileCvStructureJson,
|
string? ProfileCvStructureJson,
|
||||||
string? AvatarImageDataUrl,
|
string? AvatarImageDataUrl,
|
||||||
IList<string> Roles,
|
IList<string> Roles,
|
||||||
GoogleLinkDto? GoogleLink);
|
GoogleLinkDto? GoogleLink,
|
||||||
|
MicrosoftLinkDto? MicrosoftLink);
|
||||||
private const int MaxAvatarBytes = 1_000_000;
|
private const int MaxAvatarBytes = 1_000_000;
|
||||||
private static readonly HashSet<string> AllowedAvatarExtensions = new(StringComparer.OrdinalIgnoreCase)
|
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 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 GoogleTokenRequest(string Token, bool RememberMe = true);
|
||||||
|
public sealed record MicrosoftTokenRequest(string Token, bool RememberMe = true);
|
||||||
|
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
@@ -155,7 +162,24 @@ public sealed class AuthController : ControllerBase
|
|||||||
|
|
||||||
if (user is null)
|
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))
|
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"));
|
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")]
|
[HttpPost("logout")]
|
||||||
public IActionResult Logout()
|
public IActionResult Logout()
|
||||||
{
|
{
|
||||||
@@ -202,7 +294,11 @@ public sealed class AuthController : ControllerBase
|
|||||||
var email = User.FindFirstValue(ClaimTypes.Email) ?? User.FindFirstValue("email");
|
var email = User.FindFirstValue(ClaimTypes.Email) ?? User.FindFirstValue("email");
|
||||||
var sub = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
var sub = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||||
var iss = User.FindFirstValue("iss") ?? string.Empty;
|
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(
|
return Ok(new MeResult(
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
@@ -216,7 +312,8 @@ public sealed class AuthController : ControllerBase
|
|||||||
ProfileCvStructureJson: null,
|
ProfileCvStructureJson: null,
|
||||||
AvatarImageDataUrl: null,
|
AvatarImageDataUrl: null,
|
||||||
Roles: Array.Empty<string>(),
|
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")]
|
[HttpPut("profile")]
|
||||||
@@ -322,6 +419,76 @@ public sealed class AuthController : ControllerBase
|
|||||||
return NoContent();
|
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")]
|
[HttpPost("avatar")]
|
||||||
[Authorize(AuthenticationSchemes = "local")]
|
[Authorize(AuthenticationSchemes = "local")]
|
||||||
[RequestSizeLimit(MaxAvatarBytes)]
|
[RequestSizeLimit(MaxAvatarBytes)]
|
||||||
@@ -571,6 +738,10 @@ public sealed class AuthController : ControllerBase
|
|||||||
GoogleLink: new GoogleLinkDto(
|
GoogleLink: new GoogleLinkDto(
|
||||||
Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject),
|
Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject),
|
||||||
Email: user.GoogleEmail,
|
Email: user.GoogleEmail,
|
||||||
LinkedAt: user.GoogleLinkedAt));
|
LinkedAt: user.GoogleLinkedAt),
|
||||||
|
MicrosoftLink: new MicrosoftLinkDto(
|
||||||
|
Linked: !string.IsNullOrWhiteSpace(user.MicrosoftSubject),
|
||||||
|
Email: user.MicrosoftEmail,
|
||||||
|
LinkedAt: user.MicrosoftLinkedAt));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ builder.Services.AddSingleton<IJobCvMatchService, JobCvMatchService>();
|
|||||||
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
|
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
|
||||||
builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>();
|
builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>();
|
||||||
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
|
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
|
||||||
|
builder.Services.AddSingleton<IMicrosoftTokenValidator, MicrosoftTokenValidator>();
|
||||||
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
|
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
|
||||||
builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>();
|
builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>();
|
||||||
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
|
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
|
||||||
@@ -209,6 +210,7 @@ builder.Services.AddScoped<JobImportService>();
|
|||||||
|
|
||||||
var requireAuth = builder.Configuration.GetValue("Auth:Require", false);
|
var requireAuth = builder.Configuration.GetValue("Auth:Require", false);
|
||||||
var googleClientId = (builder.Configuration["Auth:GoogleClientId"] ?? "").Trim();
|
var googleClientId = (builder.Configuration["Auth:GoogleClientId"] ?? "").Trim();
|
||||||
|
var microsoftClientId = (builder.Configuration["Auth:MicrosoftClientId"] ?? "").Trim();
|
||||||
|
|
||||||
var jwtKey = (builder.Configuration["Auth:JwtKey"] ?? "").Trim();
|
var jwtKey = (builder.Configuration["Auth:JwtKey"] ?? "").Trim();
|
||||||
var ephemeralJwtKey = false;
|
var ephemeralJwtKey = false;
|
||||||
@@ -234,7 +236,7 @@ builder.Services.AddAuthentication(options =>
|
|||||||
{
|
{
|
||||||
options.ForwardDefaultSelector = ctx =>
|
options.ForwardDefaultSelector = ctx =>
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(googleClientId))
|
if (string.IsNullOrWhiteSpace(googleClientId) && string.IsNullOrWhiteSpace(microsoftClientId))
|
||||||
return "local";
|
return "local";
|
||||||
|
|
||||||
var auth = ctx.Request.Headers.Authorization.ToString();
|
var auth = ctx.Request.Headers.Authorization.ToString();
|
||||||
@@ -250,9 +252,11 @@ builder.Services.AddAuthentication(options =>
|
|||||||
{
|
{
|
||||||
var jwt = handler.ReadJwtToken(token);
|
var jwt = handler.ReadJwtToken(token);
|
||||||
var iss = jwt.Issuer ?? "";
|
var iss = jwt.Issuer ?? "";
|
||||||
return iss is "accounts.google.com" or "https://accounts.google.com"
|
if (!string.IsNullOrWhiteSpace(googleClientId) && iss is "accounts.google.com" or "https://accounts.google.com")
|
||||||
? "google"
|
return "google";
|
||||||
: "local";
|
if (!string.IsNullOrWhiteSpace(microsoftClientId) && iss.StartsWith("https://login.microsoftonline.com/", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return "microsoft";
|
||||||
|
return "local";
|
||||||
}
|
}
|
||||||
catch
|
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 =>
|
builder.Services.AddAuthorization(options =>
|
||||||
{
|
{
|
||||||
if (requireAuth)
|
if (requireAuth)
|
||||||
|
|||||||
@@ -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<MicrosoftTokenPrincipal> ValidateAsync(string idToken, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MicrosoftTokenValidator : IMicrosoftTokenValidator
|
||||||
|
{
|
||||||
|
private readonly IConfiguration _cfg;
|
||||||
|
private readonly IConfigurationManager<OpenIdConnectConfiguration> _configManager;
|
||||||
|
|
||||||
|
public MicrosoftTokenValidator(IConfiguration cfg)
|
||||||
|
{
|
||||||
|
_cfg = cfg;
|
||||||
|
// "common" endpoint: accepts both personal Microsoft accounts and work/school (Entra ID) tenants.
|
||||||
|
_configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
|
||||||
|
"https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration",
|
||||||
|
new OpenIdConnectConfigurationRetriever());
|
||||||
|
}
|
||||||
|
|
||||||
|
public MicrosoftTokenValidator(IConfiguration cfg, IConfigurationManager<OpenIdConnectConfiguration> configManager)
|
||||||
|
{
|
||||||
|
_cfg = cfg;
|
||||||
|
_configManager = configManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MicrosoftTokenPrincipal> 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);
|
||||||
|
}
|
||||||
@@ -241,6 +241,9 @@ public static class StartupInitializationExtensions
|
|||||||
`GoogleSubject` longtext NULL,
|
`GoogleSubject` longtext NULL,
|
||||||
`GoogleEmail` longtext NULL,
|
`GoogleEmail` longtext NULL,
|
||||||
`GoogleLinkedAt` datetime(6) NULL,
|
`GoogleLinkedAt` datetime(6) NULL,
|
||||||
|
`MicrosoftSubject` longtext NULL,
|
||||||
|
`MicrosoftEmail` longtext NULL,
|
||||||
|
`MicrosoftLinkedAt` datetime(6) NULL,
|
||||||
PRIMARY KEY (`Id`)
|
PRIMARY KEY (`Id`)
|
||||||
) CHARACTER SET=utf8mb4;
|
) CHARACTER SET=utf8mb4;
|
||||||
|
|
||||||
@@ -353,7 +356,10 @@ public static class StartupInitializationExtensions
|
|||||||
"AvatarImageDataUrl" TEXT NULL,
|
"AvatarImageDataUrl" TEXT NULL,
|
||||||
"GoogleSubject" TEXT NULL,
|
"GoogleSubject" TEXT NULL,
|
||||||
"GoogleEmail" 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", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;");
|
||||||
EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail 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", "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)
|
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", "GoogleSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleSubject` longtext NULL;");
|
||||||
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleEmail` 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", "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"))
|
if (!HasMySqlTable(conn, "RuleSettings"))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,7 +26,8 @@
|
|||||||
"JwtExpiresMinutes": 720,
|
"JwtExpiresMinutes": 720,
|
||||||
"AdminEmail": "admin@example.com",
|
"AdminEmail": "admin@example.com",
|
||||||
"AdminPassword": "CHANGE_ME_STRONG_DEV_PASSWORD",
|
"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": {
|
"App": {
|
||||||
"PublicBaseUrl": "https://jobs.cesnimda.uk"
|
"PublicBaseUrl": "https://jobs.cesnimda.uk"
|
||||||
|
|||||||
@@ -16,4 +16,7 @@ public sealed class ApplicationUser : IdentityUser
|
|||||||
public string? GoogleSubject { get; set; }
|
public string? GoogleSubject { get; set; }
|
||||||
public string? GoogleEmail { get; set; }
|
public string? GoogleEmail { get; set; }
|
||||||
public DateTimeOffset? GoogleLinkedAt { get; set; }
|
public DateTimeOffset? GoogleLinkedAt { get; set; }
|
||||||
|
public string? MicrosoftSubject { get; set; }
|
||||||
|
public string? MicrosoftEmail { get; set; }
|
||||||
|
public DateTimeOffset? MicrosoftLinkedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+22
@@ -8,6 +8,7 @@
|
|||||||
"name": "job-tracker-ui",
|
"name": "job-tracker-ui",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@azure/msal-browser": "^5.17.0",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^7.3.9",
|
"@mui/icons-material": "^7.3.9",
|
||||||
@@ -52,6 +53,27 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@azure/msal-browser": "^5.17.0",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^7.3.9",
|
"@mui/icons-material": "^7.3.9",
|
||||||
|
|||||||
@@ -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<MeResponse | null>(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<MeResponse>("/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 (
|
||||||
|
<Paper sx={{ mt: 2, p: 2 }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||||
|
{t("microsoftAccountTitle")}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{!clientId && (
|
||||||
|
<Typography sx={{ color: "text.secondary" }}>
|
||||||
|
{t("microsoftSetupHint")}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{clientId && (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||||
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||||
|
<Chip size="small" label={me?.microsoftLink?.linked ? t("microsoftLinked") : t("microsoftAvailableToLink")} color={me?.microsoftLink?.linked ? "success" : "default"} variant={me?.microsoftLink?.linked ? "filled" : "outlined"} />
|
||||||
|
{me?.microsoftLink?.linkedAt ? <Chip size="small" variant="outlined" label={t("microsoftLinkedDate", { date: new Date(me.microsoftLink.linkedAt).toLocaleDateString() })} /> : null}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{!signedIn ? (
|
||||||
|
<Typography sx={{ color: "text.secondary" }}>
|
||||||
|
{t("microsoftSignInHint")}
|
||||||
|
</Typography>
|
||||||
|
) : me?.provider === "local" ? (
|
||||||
|
<Typography sx={{ color: "text.secondary" }}>
|
||||||
|
{me.microsoftLink?.linked
|
||||||
|
? t("microsoftLinkedTo", { email: me.microsoftLink.email || t("microsoftLinkedToYourAccount") })
|
||||||
|
: t("microsoftBindHint")}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Typography sx={{ color: "text.secondary" }}>
|
||||||
|
{t("microsoftExchangeHint")}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 1 }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700, letterSpacing: 0.4, textTransform: "uppercase" }}>
|
||||||
|
{actionLabel}
|
||||||
|
</Typography>
|
||||||
|
<Button variant="outlined" disabled={working} onClick={() => void handleSignIn()}>
|
||||||
|
{actionLabel}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||||
|
{signedIn ? (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => {
|
||||||
|
void api.post("/auth/logout").catch(() => undefined).finally(() => {
|
||||||
|
clearAuthClientState();
|
||||||
|
setMe(null);
|
||||||
|
toast(t("signedOut"), "info");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("signOut")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{me?.provider === "local" && me.microsoftLink?.linked ? (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="warning"
|
||||||
|
disabled={working}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await api.delete("/auth/microsoft/link");
|
||||||
|
toast(t("microsoftUnlinked"), "info");
|
||||||
|
await refreshMe();
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.response?.data || e?.message || t("microsoftUnlinkFailed");
|
||||||
|
toast(String(msg), "error");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("unlinkMicrosoft")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{signedIn && me?.email ? (
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
{t("signedInAs", { name: signedInName })}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -628,6 +628,26 @@ export const translations = {
|
|||||||
googleScriptLoadFailed: "Google auth script failed to load.",
|
googleScriptLoadFailed: "Google auth script failed to load.",
|
||||||
googleUnlinked: "Google account unlinked.",
|
googleUnlinked: "Google account unlinked.",
|
||||||
googleUnlinkFailed: "Failed to unlink Google account.",
|
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.",
|
signedOut: "Signed out.",
|
||||||
signedInAs: "Signed in as {name}.",
|
signedInAs: "Signed in as {name}.",
|
||||||
unlinkGoogle: "Unlink Google",
|
unlinkGoogle: "Unlink Google",
|
||||||
@@ -663,6 +683,7 @@ export const translations = {
|
|||||||
authOptional: "Authentication is optional in this environment.",
|
authOptional: "Authentication is optional in this environment.",
|
||||||
emailAndPassword: "Email & password",
|
emailAndPassword: "Email & password",
|
||||||
google: "Google",
|
google: "Google",
|
||||||
|
microsoft: "Microsoft",
|
||||||
createAccount: "Create account",
|
createAccount: "Create account",
|
||||||
signedIn: "Signed in.",
|
signedIn: "Signed in.",
|
||||||
rememberMe: "Remember me",
|
rememberMe: "Remember me",
|
||||||
@@ -1572,6 +1593,26 @@ export const translations = {
|
|||||||
googleScriptLoadFailed: "Kunne ikke laste Google-autentiseringsskriptet.",
|
googleScriptLoadFailed: "Kunne ikke laste Google-autentiseringsskriptet.",
|
||||||
googleUnlinked: "Google-konto koblet fra.",
|
googleUnlinked: "Google-konto koblet fra.",
|
||||||
googleUnlinkFailed: "Kunne ikke koble fra Google-kontoen.",
|
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.",
|
signedOut: "Logget ut.",
|
||||||
signedInAs: "Logget inn som {name}.",
|
signedInAs: "Logget inn som {name}.",
|
||||||
unlinkGoogle: "Koble fra Google",
|
unlinkGoogle: "Koble fra Google",
|
||||||
@@ -1607,6 +1648,7 @@ export const translations = {
|
|||||||
authOptional: "Autentisering er valgfri i dette miljøet.",
|
authOptional: "Autentisering er valgfri i dette miljøet.",
|
||||||
emailAndPassword: "E-post og passord",
|
emailAndPassword: "E-post og passord",
|
||||||
google: "Google",
|
google: "Google",
|
||||||
|
microsoft: "Microsoft",
|
||||||
createAccount: "Opprett konto",
|
createAccount: "Opprett konto",
|
||||||
signedIn: "Logget inn.",
|
signedIn: "Logget inn.",
|
||||||
rememberMe: "Husk meg",
|
rememberMe: "Husk meg",
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import { useLocation, useNavigate } from "react-router-dom";
|
|||||||
import { api, getApiErrorMessage } from "../api";
|
import { api, getApiErrorMessage } from "../api";
|
||||||
import { getRememberMePref, setAuthPersistencePreference } from "../auth";
|
import { getRememberMePref, setAuthPersistencePreference } from "../auth";
|
||||||
import GoogleAuthCard from "../components/GoogleAuthCard";
|
import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||||
|
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
|
|
||||||
type AuthConfig = {
|
type AuthConfig = {
|
||||||
requireAuth: boolean;
|
requireAuth: boolean;
|
||||||
googleEnabled: boolean;
|
googleEnabled: boolean;
|
||||||
|
microsoftEnabled: boolean;
|
||||||
localEnabled: boolean;
|
localEnabled: boolean;
|
||||||
allowRegistration: boolean;
|
allowRegistration: boolean;
|
||||||
};
|
};
|
||||||
@@ -81,6 +83,7 @@ export default function LoginPage() {
|
|||||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||||
<Tab label={t("emailAndPassword")} />
|
<Tab label={t("emailAndPassword")} />
|
||||||
<Tab label={t("google")} />
|
<Tab label={t("google")} />
|
||||||
|
<Tab label={t("microsoft")} />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{tab === 0 && (
|
{tab === 0 && (
|
||||||
@@ -123,6 +126,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 1 && <GoogleAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
|
{tab === 1 && <GoogleAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
|
||||||
|
{tab === 2 && <MicrosoftAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined";
|
|||||||
|
|
||||||
import { api, getApiErrorMessage } from "../api";
|
import { api, getApiErrorMessage } from "../api";
|
||||||
import GoogleAuthCard from "../components/GoogleAuthCard";
|
import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||||
|
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
|
||||||
import CropImageDialog from "../components/CropImageDialog";
|
import CropImageDialog from "../components/CropImageDialog";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
@@ -562,6 +563,7 @@ export default function ProfilePage() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<GoogleAuthCard />
|
<GoogleAuthCard />
|
||||||
|
<MicrosoftAuthCard />
|
||||||
|
|
||||||
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||||
|
|||||||
@@ -29,12 +29,13 @@ jest.mock('./api', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('./components/GoogleAuthCard', () => () => null);
|
jest.mock('./components/GoogleAuthCard', () => () => null);
|
||||||
|
jest.mock('./components/MicrosoftAuthCard', () => () => null);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const { api } = require('./api');
|
const { api } = require('./api');
|
||||||
api.get.mockImplementation((url: string) => {
|
api.get.mockImplementation((url: string) => {
|
||||||
if (url === '/auth/config') {
|
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') {
|
if (url === '/auth/me') {
|
||||||
return Promise.resolve({ data: { roles: [], email: 'demo@example.com', userName: 'demo' } });
|
return Promise.resolve({ data: { roles: [], email: 'demo@example.com', userName: 'demo' } });
|
||||||
|
|||||||
Reference in New Issue
Block a user