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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user