feat(auth): Microsoft OAuth sign-in/link + self-serve signup via Google/Microsoft #22

Merged
cesnimda merged 1 commits from feature/wave7-oauth-signup into main 2026-07-12 01:15:41 +02:00
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()
{
@@ -51,7 +51,7 @@ public sealed class ClientErrorsControllerTests
var userManager = TestHostFactory.CreateUserManager();
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
{
@@ -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));
}
}
+177 -6
View File
@@ -19,15 +19,17 @@ public sealed class AuthController : ControllerBase
private readonly ITokenService _tokens;
private readonly IAppEmailSender _email;
private readonly IGoogleTokenValidator _googleTokens;
private readonly IMicrosoftTokenValidator _microsoftTokens;
private readonly ILogger<AuthController> _logger;
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, ILogger<AuthController> logger)
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger)
{
_cfg = cfg;
_users = users;
_tokens = tokens;
_email = email;
_googleTokens = googleTokens;
_microsoftTokens = microsoftTokens;
_logger = logger;
}
@@ -37,12 +39,14 @@ public sealed class AuthController : ControllerBase
{
var requireAuth = _cfg.GetValue("Auth:Require", false);
var googleEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? string.Empty).Trim());
var microsoftEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:MicrosoftClientId"] ?? string.Empty).Trim());
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
return Ok(new
{
requireAuth,
googleEnabled,
microsoftEnabled,
localEnabled = true,
allowRegistration,
});
@@ -52,6 +56,7 @@ public sealed class AuthController : ControllerBase
public sealed record RegisterRequest(string Email, string Password, bool RememberMe = true);
public sealed record AuthSessionResult(bool Authenticated, string Provider);
public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
public sealed record MicrosoftLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
public sealed record MeResult(
string Provider,
string? Id,
@@ -64,7 +69,8 @@ public sealed class AuthController : ControllerBase
string? ProfileCvStructureJson,
string? AvatarImageDataUrl,
IList<string> Roles,
GoogleLinkDto? GoogleLink);
GoogleLinkDto? GoogleLink,
MicrosoftLinkDto? MicrosoftLink);
private const int MaxAvatarBytes = 1_000_000;
private static readonly HashSet<string> AllowedAvatarExtensions = new(StringComparer.OrdinalIgnoreCase)
{
@@ -72,6 +78,7 @@ public sealed class AuthController : ControllerBase
};
public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName, string? ProfileCvText, string? ProfileCvStructureJson);
public sealed record GoogleTokenRequest(string Token, bool RememberMe = true);
public sealed record MicrosoftTokenRequest(string Token, bool RememberMe = true);
[HttpPost("login")]
[AllowAnonymous]
@@ -155,7 +162,24 @@ public sealed class AuthController : ControllerBase
if (user is null)
{
return Unauthorized("This Google account is not linked to a Jobbjakt user yet.");
if (!google.EmailVerified || string.IsNullOrWhiteSpace(google.Email))
{
return Unauthorized("This Google account is not linked to a Jobbjakt user yet.");
}
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
if (!allowRegistration)
{
return Unauthorized("This Google account is not linked to a Jobbjakt user yet.");
}
user = new ApplicationUser { UserName = google.Email, Email = google.Email, EmailConfirmed = true };
var created = await _users.CreateAsync(user);
if (!created.Succeeded)
{
return BadRequest(string.Join("; ", created.Errors.Select(e => e.Description)));
}
_logger.LogInformation("Created new user via Google sign-up for {Email}", google.Email);
}
if (string.IsNullOrWhiteSpace(user.GoogleSubject) || !string.Equals(user.GoogleSubject, google.Subject, StringComparison.Ordinal))
@@ -173,6 +197,74 @@ public sealed class AuthController : ControllerBase
return Ok(new AuthSessionResult(true, "google"));
}
[HttpPost("microsoft/exchange")]
[AllowAnonymous]
[EnableRateLimiting("auth-login")]
public async Task<ActionResult<AuthSessionResult>> ExchangeMicrosoftToken([FromBody] MicrosoftTokenRequest request, CancellationToken cancellationToken)
{
var token = (request.Token ?? string.Empty).Trim();
if (token.Length == 0) return BadRequest("Microsoft token is required.");
MicrosoftTokenPrincipal microsoft;
try
{
microsoft = await _microsoftTokens.ValidateAsync(token, cancellationToken);
}
catch (Exception ex)
{
return Unauthorized(ex.Message);
}
var user = await _users.Users.FirstOrDefaultAsync(
x => x.MicrosoftSubject == microsoft.Subject || (!string.IsNullOrWhiteSpace(microsoft.Email) && x.MicrosoftEmail == microsoft.Email),
cancellationToken);
if (user is null && microsoft.EmailVerified && !string.IsNullOrWhiteSpace(microsoft.Email))
{
user = await _users.FindByEmailAsync(microsoft.Email);
if (user is not null)
{
_logger.LogInformation("Auto-linking Microsoft sign-in for existing local account {Email}", microsoft.Email);
}
}
if (user is null)
{
if (!microsoft.EmailVerified || string.IsNullOrWhiteSpace(microsoft.Email))
{
return Unauthorized("This Microsoft account is not linked to a Jobbjakt user yet.");
}
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
if (!allowRegistration)
{
return Unauthorized("This Microsoft account is not linked to a Jobbjakt user yet.");
}
user = new ApplicationUser { UserName = microsoft.Email, Email = microsoft.Email, EmailConfirmed = true };
var created = await _users.CreateAsync(user);
if (!created.Succeeded)
{
return BadRequest(string.Join("; ", created.Errors.Select(e => e.Description)));
}
_logger.LogInformation("Created new user via Microsoft sign-up for {Email}", microsoft.Email);
}
if (string.IsNullOrWhiteSpace(user.MicrosoftSubject) || !string.Equals(user.MicrosoftSubject, microsoft.Subject, StringComparison.Ordinal))
{
user.MicrosoftSubject = microsoft.Subject;
user.MicrosoftEmail = microsoft.Email;
user.MicrosoftLinkedAt ??= DateTimeOffset.UtcNow;
user.DisplayName ??= TrimOrNull(microsoft.Name);
user.FirstName ??= TrimOrNull(microsoft.GivenName);
user.LastName ??= TrimOrNull(microsoft.FamilyName);
await _users.UpdateAsync(user);
}
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken);
return Ok(new AuthSessionResult(true, "microsoft"));
}
[HttpPost("logout")]
public IActionResult Logout()
{
@@ -202,7 +294,11 @@ public sealed class AuthController : ControllerBase
var email = User.FindFirstValue(ClaimTypes.Email) ?? User.FindFirstValue("email");
var sub = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
var iss = User.FindFirstValue("iss") ?? string.Empty;
var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase) ? "google" : "external";
var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase)
? "google"
: iss.Contains("login.microsoftonline.com", StringComparison.OrdinalIgnoreCase)
? "microsoft"
: "external";
return Ok(new MeResult(
Provider: provider,
@@ -216,7 +312,8 @@ public sealed class AuthController : ControllerBase
ProfileCvStructureJson: null,
AvatarImageDataUrl: null,
Roles: Array.Empty<string>(),
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null));
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null,
MicrosoftLink: provider == "microsoft" ? new MicrosoftLinkDto(false, email, null) : null));
}
[HttpPut("profile")]
@@ -322,6 +419,76 @@ public sealed class AuthController : ControllerBase
return NoContent();
}
[HttpPost("microsoft/link")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<MicrosoftLinkDto>> LinkMicrosoft([FromBody] MicrosoftTokenRequest request, CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
var token = (request.Token ?? string.Empty).Trim();
if (token.Length == 0) return BadRequest("Microsoft token is required.");
MicrosoftTokenPrincipal microsoft;
try
{
microsoft = await _microsoftTokens.ValidateAsync(token, cancellationToken);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
var conflict = await _users.Users
.Where(x => x.Id != user.Id)
.FirstOrDefaultAsync(x => x.MicrosoftSubject == microsoft.Subject || (!string.IsNullOrWhiteSpace(microsoft.Email) && x.MicrosoftEmail == microsoft.Email), cancellationToken);
if (conflict is not null)
{
return Conflict("That Microsoft account is already linked to another Jobbjakt user.");
}
user.MicrosoftSubject = microsoft.Subject;
user.MicrosoftEmail = microsoft.Email;
user.MicrosoftLinkedAt = DateTimeOffset.UtcNow;
user.DisplayName ??= TrimOrNull(microsoft.Name);
user.FirstName ??= TrimOrNull(microsoft.GivenName);
user.LastName ??= TrimOrNull(microsoft.FamilyName);
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
return Ok(new MicrosoftLinkDto(true, user.MicrosoftEmail, user.MicrosoftLinkedAt));
}
[HttpDelete("microsoft/link")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> UnlinkMicrosoft()
{
var user = await _users.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
user.MicrosoftSubject = null;
user.MicrosoftEmail = null;
user.MicrosoftLinkedAt = null;
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
return NoContent();
}
[HttpPost("avatar")]
[Authorize(AuthenticationSchemes = "local")]
[RequestSizeLimit(MaxAvatarBytes)]
@@ -571,6 +738,10 @@ public sealed class AuthController : ControllerBase
GoogleLink: new GoogleLinkDto(
Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject),
Email: user.GoogleEmail,
LinkedAt: user.GoogleLinkedAt));
LinkedAt: user.GoogleLinkedAt),
MicrosoftLink: new MicrosoftLinkDto(
Linked: !string.IsNullOrWhiteSpace(user.MicrosoftSubject),
Email: user.MicrosoftEmail,
LinkedAt: user.MicrosoftLinkedAt));
}
}
+25 -4
View File
@@ -162,6 +162,7 @@ builder.Services.AddSingleton<IJobCvMatchService, JobCvMatchService>();
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>();
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
builder.Services.AddSingleton<IMicrosoftTokenValidator, MicrosoftTokenValidator>();
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>();
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
@@ -209,6 +210,7 @@ builder.Services.AddScoped<JobImportService>();
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)
@@ -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,
`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"))
{
+2 -1
View File
@@ -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"
+3
View File
@@ -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; }
}
+22
View File
@@ -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",
+1
View File
@@ -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",
@@ -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>
);
}
+42
View File
@@ -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",
+4
View File
@@ -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() {
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
<Tab label={t("emailAndPassword")} />
<Tab label={t("google")} />
<Tab label={t("microsoft")} />
</Tabs>
{tab === 0 && (
@@ -123,6 +126,7 @@ export default function LoginPage() {
)}
{tab === 1 && <GoogleAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
{tab === 2 && <MicrosoftAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
</Paper>
</Box>
);
+2
View File
@@ -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() {
</Box>
<GoogleAuthCard />
<MicrosoftAuthCard />
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<Box sx={{ gridColumn: "1 / -1" }}>
+2 -1
View File
@@ -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' } });