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

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

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

Frontend: new MicrosoftAuthCard (MSAL popup flow -- Microsoft has no
vanilla-JS equivalent to Google's Identity Services script) wired into
the login page's provider tabs and the profile page's account-linking
section. REACT_APP_MICROSOFT_CLIENT_ID env var, Auth:MicrosoftClientId
config gate on the backend.
This commit is contained in:
cesnimda
2026-07-12 00:12:23 +02:00
parent fc62a659ef
commit 3081d99355
16 changed files with 729 additions and 17 deletions
@@ -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.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));
@@ -50,7 +50,7 @@ public sealed class AuthAndSystemControllerTests
.Setup(x => x.SendAsync(user.Email!, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.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
{
@@ -91,7 +91,7 @@ public sealed class AuthAndSystemControllerTests
.Setup(x => x.ValidateAsync("google-token", It.IsAny<CancellationToken>()))
.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
{
@@ -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<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]
public void Me_result_includes_google_link_details_for_local_users()
{