Add provider-neutral Google authentication

This commit is contained in:
hz
2026-07-20 21:42:56 -05:00
parent 48e19f138e
commit 72fd1e3e0f
20 changed files with 1084 additions and 36 deletions
+47
View File
@@ -86,6 +86,53 @@ public sealed class AuthTests
Assert.Null(SteamOpenIdService.ExtractSteamId("https://steamcommunity.com/openid/id/123"));
}
[Fact]
public void GoogleProviderIsDisabledUntilAllSecureConfigurationExists()
{
var incomplete = new GoogleExternalAuthProvider(new TestHttpClientFactory(), Configuration(new Dictionary<string, string?>
{
["Google:ClientId"] = "client-id",
["Google:ReturnUrl"] = "https://example.test/api/Auth/external/google/callback"
}));
var complete = new GoogleExternalAuthProvider(new TestHttpClientFactory(), Configuration(new Dictionary<string, string?>
{
["Google:ClientId"] = "client-id",
["Google:ClientSecret"] = "client-secret",
["Google:ReturnUrl"] = "https://example.test/api/Auth/external/google/callback"
}));
Assert.False(incomplete.IsConfigured);
Assert.True(complete.IsConfigured);
}
[Fact]
public void GoogleAuthorizationUsesStateNonceAndPkce()
{
var provider = new GoogleExternalAuthProvider(new TestHttpClientFactory(), Configuration(new Dictionary<string, string?>
{
["Google:ClientId"] = "client-id",
["Google:ClientSecret"] = "client-secret",
["Google:ReturnUrl"] = "https://example.test/api/Auth/external/google/callback"
}));
var url = provider.BuildAuthorizationUrl(new ExternalAuthTransaction
{
Id = "transaction-state",
Nonce = "transaction-nonce",
CodeVerifier = "a-long-test-code-verifier"
});
Assert.Contains("state=transaction-state", url);
Assert.Contains("nonce=transaction-nonce", url);
Assert.Contains("code_challenge_method=S256", url);
Assert.Contains("scope=openid%20email%20profile", url);
Assert.DoesNotContain("client-secret", url);
}
private static IConfiguration Configuration(Dictionary<string, string?> values) =>
new ConfigurationBuilder().AddInMemoryCollection(values).Build();
private sealed class TestHttpClientFactory : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new();
}
}
+20 -2
View File
@@ -20,6 +20,9 @@ public sealed class AuthController(
UserProfileStore profiles,
PasswordResetStore passwordResets,
SteamAuthTransactionStore steamTransactions,
ExternalIdentityStore externalIdentities,
ExternalAuthTransactionStore externalTransactions,
IEnumerable<IExternalAuthProvider> externalProviders,
PasswordResetCodeService resetCodes,
IPasswordResetEmailSender resetEmail,
IConfiguration configuration,
@@ -109,7 +112,7 @@ public sealed class AuthController(
[HttpGet("me")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Me() =>
await CurrentUserAsync() is { } user ? Ok(ProfileResponse(user)) : Unauthorized();
await CurrentUserAsync() is { } user ? Ok(await ProfileResponseAsync(user)) : Unauthorized();
[HttpPost("legal/accept")]
[Authorize(Roles = "USER,SUPER")]
@@ -152,7 +155,7 @@ public sealed class AuthController(
{
return Conflict("Email address is already in use.");
}
return Ok(ProfileResponse(user));
return Ok(await ProfileResponseAsync(user));
}
[HttpPost("me/change-password")]
@@ -186,6 +189,7 @@ public sealed class AuthController(
await profiles.PurgeAsync(user.Id);
await passwordResets.DeleteForUserAsync(user.Id);
await steamTransactions.DeleteForUserAsync(user.Id);
await externalTransactions.DeleteForUserAsync(user.Id);
await RevokeCurrentTokenAsync();
await users.DeleteAsync(user.Id);
return NoContent();
@@ -302,6 +306,20 @@ public sealed class AuthController(
legal = legal.Status(user)
};
private async Task<object> ProfileResponseAsync(AuthUser user) => new
{
userId = user.Id,
user.Username,
user.DisplayName,
user.Email,
user.Role,
createdUtc = user.CreatedUtc ?? CreationDate(user),
connectedProviders = await externalIdentities.ProvidersForUserAsync(user.Id),
availableProviders = externalProviders.Where(provider => provider.IsConfigured)
.Select(provider => provider.Id).OrderBy(provider => provider),
legal = legal.Status(user)
};
private object SessionResponse(AuthUser user, string accessToken, DateTime expiresUtc,
string? jti = null, string? refreshToken = null) => new
{
@@ -0,0 +1,296 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using MongoDB.Driver;
using QuestionsApi.Models;
using QuestionsApi.Services;
using System.Security.Claims;
using System.Security.Cryptography;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/Auth/external")]
public sealed class ExternalAuthController(
IEnumerable<IExternalAuthProvider> providerServices,
ExternalIdentityStore identities,
ExternalAuthTransactionStore transactions,
AuthUserStore users,
JwtTokenService tokens,
LegalPolicy legal,
IConfiguration configuration,
ILogger<ExternalAuthController> logger) : ControllerBase
{
private readonly Dictionary<string, IExternalAuthProvider> _providers = providerServices
.ToDictionary(value => value.Id, StringComparer.OrdinalIgnoreCase);
[HttpGet("providers")]
[AllowAnonymous]
public IActionResult Providers() => Ok(new
{
providers = _providers.Values.Where(value => value.IsConfigured)
.OrderBy(value => value.DisplayName)
.Select(value => new { id = value.Id, name = value.DisplayName })
});
[HttpPost("{provider}/start")]
[AllowAnonymous]
[EnableRateLimiting("external-start")]
public async Task<IActionResult> Start(string provider, ExternalAuthStartRequest? request)
{
if (!TryProvider(provider, out var service)) return NotFound(new { message = "Provider is unavailable." });
var purpose = (request?.Purpose ?? "login").Trim().ToLowerInvariant();
if (purpose is not ("login" or "link")) return BadRequest(new { message = "Purpose must be login or link." });
string? userId = null;
if (purpose == "link")
{
userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (await identities.GetForUserAsync(userId, service.Id) is not null)
return Conflict(new { message = $"This account is already connected to {service.DisplayName}." });
}
var now = DateTime.UtcNow;
var lifetime = Math.Clamp(configuration.GetValue("ExternalAuth:TransactionTtlMinutes", 5), 2, 15);
var transaction = new ExternalAuthTransaction
{
Id = Base64Url(RandomNumberGenerator.GetBytes(32)),
Provider = service.Id,
Purpose = purpose,
UserId = userId,
Nonce = GoogleExternalAuthProvider.NewNonce(),
CodeVerifier = GoogleExternalAuthProvider.NewVerifier(),
CreatedUtc = now,
ExpiresUtc = now.AddMinutes(lifetime)
};
await transactions.CreateAsync(transaction);
return Ok(new
{
transactionId = transaction.Id,
provider = service.Id,
authUrl = service.BuildAuthorizationUrl(transaction),
transaction.ExpiresUtc
});
}
[HttpGet("{provider}/callback")]
[AllowAnonymous]
[EnableRateLimiting("external-complete")]
public async Task<IActionResult> Callback(
string provider,
[FromQuery] string? transactionId,
[FromQuery] string? state,
CancellationToken cancellationToken)
{
if (!TryProvider(provider, out var service))
return CallbackPage(false, "External sign-in", "This provider is unavailable.");
var callbackTransactionId = string.IsNullOrWhiteSpace(transactionId) ? state : transactionId;
var transaction = await ActiveTransactionAsync(callbackTransactionId ?? "");
if (transaction is null || transaction.Status != "pending" || transaction.Provider != service.Id)
return CallbackPage(false, service.DisplayName, "This sign-in request has expired. Return to RanaZe and try again.");
var claims = await service.VerifyCallbackAsync(Request.Query, transaction, cancellationToken);
if (claims is null)
{
logger.LogWarning("{Provider} external identity verification failed", service.Id);
return CallbackPage(false, service.DisplayName, $"{service.DisplayName} could not verify this sign-in.");
}
if (transaction.Purpose == "link")
{
if (string.IsNullOrWhiteSpace(transaction.UserId) ||
!await identities.TryLinkAsync(transaction.UserId, service.Id, claims) ||
!await transactions.MarkVerifiedAsync(transaction.Id, claims, "linked", transaction.UserId))
return CallbackPage(false, service.DisplayName,
$"That {service.DisplayName} identity is already connected to another account.");
return CallbackPage(true, service.DisplayName, $"{service.DisplayName} is now connected to your RanaZe account.");
}
var identity = await identities.GetAsync(service.Id, claims.Subject);
var user = identity is null ? null : await users.GetByIdAsync(identity.UserId);
var status = user is null ? "requires_account" : "complete";
if (!await transactions.MarkVerifiedAsync(transaction.Id, claims, status, user?.Id))
return CallbackPage(false, service.DisplayName, "This sign-in request has already been used.");
if (user is not null)
{
await identities.TouchAsync(identity!.Id);
user.RefreshToken = JwtTokenService.NewRefreshToken();
user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7);
await users.UpdateAsync(user);
}
return CallbackPage(true, service.DisplayName,
$"{service.DisplayName} verified your identity. You can return to RanaZe.");
}
[HttpGet("{provider}/status/{transactionId}")]
[AllowAnonymous]
[EnableRateLimiting("external-poll")]
public async Task<IActionResult> Status(string provider, string transactionId)
{
Response.Headers.CacheControl = "no-store";
if (!TryProvider(provider, out var service)) return NotFound(new { message = "Provider is unavailable." });
var transaction = await ActiveTransactionAsync(transactionId);
if (transaction is null || transaction.Provider != service.Id)
return NotFound(new { message = $"{service.DisplayName} sign-in expired." });
if (transaction.Status == "requires_account")
return Ok(new
{
status = transaction.Status,
provider = service.Id,
providerName = service.DisplayName,
email = transaction.EmailVerified ? transaction.ProviderEmail : null,
emailVerified = transaction.EmailVerified
});
if (transaction.Status is "pending" or "linked" or "failed")
return Ok(new { status = transaction.Status, provider = service.Id, message = transaction.Error });
if (transaction.Status != "complete" || string.IsNullOrWhiteSpace(transaction.UserId))
return Conflict(new { message = $"{service.DisplayName} sign-in is incomplete." });
var user = await users.GetByIdAsync(transaction.UserId);
if (user is null || transaction.Subject is null ||
await identities.GetAsync(service.Id, transaction.Subject) is not { } identity ||
identity.UserId != user.Id)
return Conflict(new { message = $"{service.DisplayName} account link is no longer valid." });
if (string.IsNullOrWhiteSpace(user.RefreshToken) || user.RefreshTokenExpiry <= DateTime.UtcNow)
{
user.RefreshToken = JwtTokenService.NewRefreshToken();
user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7);
await users.UpdateAsync(user);
}
var (accessToken, jti, expiresUtc) = tokens.Generate(user);
return Ok(new
{
status = "complete",
accessToken,
refreshToken = user.RefreshToken,
user.Username,
user.Role,
jti,
exp = expiresUtc,
legal = legal.Status(user)
});
}
[HttpPost("{provider}/complete")]
[AllowAnonymous]
[EnableRateLimiting("external-complete")]
public async Task<IActionResult> Complete(string provider, CompleteExternalAuthRequest request)
{
if (!TryProvider(provider, out var service)) return NotFound(new { message = "Provider is unavailable." });
var transaction = await ActiveTransactionAsync(request.TransactionId);
if (transaction?.Status == "complete") return Ok(new { status = "complete" });
if (transaction is null || transaction.Provider != service.Id || transaction.Status != "requires_account" ||
string.IsNullOrWhiteSpace(transaction.Subject))
return BadRequest(new { message = $"{service.DisplayName} sign-in is not ready or has expired." });
if (await identities.GetAsync(service.Id, transaction.Subject) is not null)
return Conflict(new { message = $"That {service.DisplayName} identity is already connected." });
var claims = new ExternalProviderClaims(
transaction.Subject, transaction.ProviderEmail, transaction.EmailVerified);
AuthUser user;
var createdUser = false;
if (request.Action == "create")
{
if (!request.AcceptLegal)
return BadRequest(new { message = "You must accept the current legal agreements." });
var username = request.Username.Trim();
var email = service.Id == ExternalAuthProviders.Google
? AuthValidation.NormalizeEmail(transaction.ProviderEmail ?? "")
: AuthValidation.NormalizeEmail(request.Email ?? "");
if (service.Id == ExternalAuthProviders.Google && !transaction.EmailVerified)
return BadRequest(new { message = "Google did not provide a verified email address." });
if (AuthValidation.ValidateNewAccount(username, email, request.Password) is { } validation)
return BadRequest(new { message = validation });
if (await users.GetByUsernameAsync(username) is not null)
return Conflict(new { message = "Username already exists." });
if (await users.EmailExistsAsync(email))
return Conflict(new
{
message = "That email already belongs to a RanaZe account. Link the existing account with its password."
});
user = new AuthUser
{
Username = username,
Email = email,
NormalizedEmail = email,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
Role = "USER",
CreatedUtc = DateTime.UtcNow
};
legal.Accept(user, $"{service.Id}_registration");
try
{
await users.CreateAsync(user);
createdUser = true;
}
catch (MongoWriteException exception) when (
exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
return Conflict(new { message = "That username or email is already in use." });
}
}
else if (request.Action == "link")
{
var candidates = await users.GetByLoginCandidatesAsync(request.Username);
user = candidates.FirstOrDefault(candidate =>
BCrypt.Net.BCrypt.Verify(request.Password, candidate.PasswordHash))!;
if (user is null) return Unauthorized(new { message = "The account credentials are incorrect." });
if (await identities.GetForUserAsync(user.Id, service.Id) is not null)
return Conflict(new { message = $"That RanaZe account already has a {service.DisplayName} identity." });
}
else
{
return BadRequest(new { message = "Action must be create or link." });
}
if (!await identities.TryLinkAsync(user.Id, service.Id, claims))
{
if (createdUser) await users.DeleteAsync(user.Id);
return Conflict(new { message = $"That {service.DisplayName} identity is already connected." });
}
if (!await transactions.MarkCompleteAsync(transaction.Id, transaction.Subject, user.Id))
{
if (createdUser)
{
await identities.Collection.DeleteManyAsync(value => value.UserId == user.Id);
await users.DeleteAsync(user.Id);
}
return Conflict(new { message = "This external sign-in was already completed." });
}
return Ok(new { status = "complete" });
}
private bool TryProvider(string provider, out IExternalAuthProvider service)
{
if (_providers.TryGetValue(provider.Trim(), out service!) && service.IsConfigured) return true;
service = null!;
return false;
}
private async Task<ExternalAuthTransaction?> ActiveTransactionAsync(string id)
{
if (string.IsNullOrWhiteSpace(id) || id.Length > 128) return null;
var transaction = await transactions.GetAsync(id);
return transaction is not null && transaction.ExpiresUtc > DateTime.UtcNow ? transaction : null;
}
private ContentResult CallbackPage(bool success, string provider, string message)
{
Response.Headers.CacheControl = "no-store";
Response.Headers.ContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'";
Response.Headers.XFrameOptions = "DENY";
Response.Headers["Referrer-Policy"] = "no-referrer";
var color = success ? "#22c55e" : "#ef4444";
var safeProvider = System.Net.WebUtility.HtmlEncode(provider);
var safeMessage = System.Net.WebUtility.HtmlEncode(message);
var html = $$"""
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>RanaZe {{safeProvider}} sign-in</title><style>body{font-family:system-ui;background:#081324;color:#f8fafc;display:grid;place-items:center;min-height:100vh;margin:0}.card{max-width:34rem;padding:2rem;border:1px solid #33415d;border-radius:8px}h1{color:{{color}};font-size:1.4rem}</style></head>
<body><main class="card"><h1>{{safeProvider}} sign-in {{(success ? "complete" : "failed")}}</h1><p>{{safeMessage}}</p></main>
<script>if(window.opener){setTimeout(()=>window.close(),1200);}</script></body></html>
""";
return Content(html, "text/html; charset=utf-8");
}
private static string Base64Url(byte[] bytes) =>
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
@@ -0,0 +1,63 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public static class ExternalAuthProviders
{
public const string Steam = "steam";
public const string Google = "google";
public static bool Supported(string provider) => provider is Steam or Google;
}
public sealed class ExternalIdentity
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
public string UserId { get; set; } = "";
public string Provider { get; set; } = "";
public string Subject { get; set; } = "";
public string? Email { get; set; }
public bool EmailVerified { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime LastUsedUtc { get; set; }
}
public sealed class ExternalAuthTransaction
{
[BsonId] public string Id { get; set; } = "";
public string Provider { get; set; } = "";
public string Purpose { get; set; } = "login";
public string Status { get; set; } = "pending";
public string? Subject { get; set; }
public string? ProviderEmail { get; set; }
public bool EmailVerified { get; set; }
public string? UserId { get; set; }
public string? Nonce { get; set; }
public string? CodeVerifier { get; set; }
public string? Error { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime ExpiresUtc { get; set; }
}
public sealed class ExternalAuthStartRequest
{
public string Purpose { get; set; } = "login";
}
public sealed class CompleteExternalAuthRequest
{
public string TransactionId { get; set; } = "";
public string Action { get; set; } = "";
public string Username { get; set; } = "";
public string? Email { get; set; }
public string Password { get; set; } = "";
public bool AcceptLegal { get; set; }
}
public sealed record ExternalProviderClaims(
string Subject,
string? Email,
bool EmailVerified);
+7
View File
@@ -49,6 +49,10 @@ builder.Services.AddSingleton<JwtTokenService>();
builder.Services.AddSingleton<LegalPolicy>();
builder.Services.AddSingleton<SteamAuthTransactionStore>();
builder.Services.AddSingleton<SteamOpenIdService>();
builder.Services.AddSingleton<ExternalIdentityStore>();
builder.Services.AddSingleton<ExternalAuthTransactionStore>();
builder.Services.AddSingleton<IExternalAuthProvider, SteamExternalAuthProvider>();
builder.Services.AddSingleton<IExternalAuthProvider, GoogleExternalAuthProvider>();
builder.Services.AddSingleton<PasswordResetStore>();
builder.Services.AddSingleton<PasswordResetCodeService>();
builder.Services.AddSingleton<IPasswordResetEmailSender, SmtpPasswordResetEmailSender>();
@@ -78,6 +82,9 @@ builder.Services.AddRateLimiter(options =>
options.AddPolicy("steam-start", context => Fixed(context, 10, TimeSpan.FromMinutes(1)));
options.AddPolicy("steam-poll", context => Fixed(context, 120, TimeSpan.FromMinutes(1)));
options.AddPolicy("steam-complete", context => Fixed(context, 20, TimeSpan.FromMinutes(1)));
options.AddPolicy("external-start", context => Fixed(context, 10, TimeSpan.FromMinutes(1)));
options.AddPolicy("external-poll", context => Fixed(context, 120, TimeSpan.FromMinutes(1)));
options.AddPolicy("external-complete", context => Fixed(context, 20, TimeSpan.FromMinutes(1)));
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
@@ -8,12 +8,17 @@ public sealed class AuthDataInitializer(
AuthUserStore users,
TokenBlacklistStore blacklist,
SteamAuthTransactionStore steamTransactions,
ExternalIdentityStore externalIdentities,
ExternalAuthTransactionStore externalTransactions,
PasswordResetStore passwordResets,
ILogger<AuthDataInitializer> logger)
{
public async Task InitializeAsync()
{
await BackfillNormalizedEmailsAsync();
await externalIdentities.BackfillSteamAsync(logger);
await externalIdentities.EnsureIndexesAsync();
await externalTransactions.EnsureIndexesAsync();
await users.Collection.Indexes.CreateManyAsync([
new CreateIndexModel<AuthUser>(
@@ -70,6 +70,15 @@ public sealed class AuthUserStore(MongoDatabaseProvider mongo)
return (await _users.UpdateOneAsync(filter, update)).MatchedCount == 1;
}
public async Task<bool> TrySetSteamIdAsync(string userId, string steamId)
{
var filter = Builders<AuthUser>.Filter.Eq(user => user.Id, userId) &
(Builders<AuthUser>.Filter.Eq(user => user.SteamId, null) |
Builders<AuthUser>.Filter.Eq(user => user.SteamId, steamId));
return (await _users.UpdateOneAsync(filter,
Builders<AuthUser>.Update.Set(user => user.SteamId, steamId))).MatchedCount == 1;
}
public async Task<bool> ResetPasswordAsync(string userId, string passwordHash)
{
var update = Builders<AuthUser>.Update
@@ -0,0 +1,173 @@
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using QuestionsApi.Models;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace QuestionsApi.Services;
public interface IExternalAuthProvider
{
string Id { get; }
string DisplayName { get; }
bool IsConfigured { get; }
string CallbackUrl { get; }
string BuildAuthorizationUrl(ExternalAuthTransaction transaction);
Task<ExternalProviderClaims?> VerifyCallbackAsync(
IQueryCollection query,
ExternalAuthTransaction transaction,
CancellationToken cancellationToken);
}
public sealed class SteamExternalAuthProvider(
SteamOpenIdService openId,
IConfiguration configuration) : IExternalAuthProvider
{
public string Id => ExternalAuthProviders.Steam;
public string DisplayName => "Steam";
public bool IsConfigured => true;
public string CallbackUrl => configuration["ExternalAuth:SteamReturnUrl"] ??
$"{Realm()}/api/Auth/external/steam/callback";
public string BuildAuthorizationUrl(ExternalAuthTransaction transaction) =>
SteamOpenIdService.BuildAuthenticationUrl(Realm(), CallbackUrlWithTransaction(transaction.Id));
public async Task<ExternalProviderClaims?> VerifyCallbackAsync(
IQueryCollection query,
ExternalAuthTransaction transaction,
CancellationToken cancellationToken)
{
var subject = await openId.VerifyCallbackAsync(
query, CallbackUrlWithTransaction(transaction.Id), cancellationToken);
return subject is null ? null : new ExternalProviderClaims(subject, null, false);
}
private string Realm() =>
(configuration["Steam:Realm"] ?? "https://s3q.ranaze.com").TrimEnd('/');
private string CallbackUrlWithTransaction(string transactionId) =>
$"{CallbackUrl}?transactionId={Uri.EscapeDataString(transactionId)}";
}
public sealed class GoogleExternalAuthProvider : IExternalAuthProvider
{
private const string AuthorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth";
private const string TokenEndpoint = "https://oauth2.googleapis.com/token";
private const string DiscoveryEndpoint = "https://accounts.google.com/.well-known/openid-configuration";
private readonly IHttpClientFactory _httpClients;
private readonly IConfiguration _configuration;
private readonly IConfigurationManager<OpenIdConnectConfiguration> _discovery;
public GoogleExternalAuthProvider(IHttpClientFactory httpClients, IConfiguration configuration)
{
_httpClients = httpClients;
_configuration = configuration;
_discovery = new ConfigurationManager<OpenIdConnectConfiguration>(
DiscoveryEndpoint, new OpenIdConnectConfigurationRetriever());
}
public string Id => ExternalAuthProviders.Google;
public string DisplayName => "Google";
public string CallbackUrl => _configuration["Google:ReturnUrl"] ?? "";
public bool IsConfigured =>
!string.IsNullOrWhiteSpace(ClientId) &&
!string.IsNullOrWhiteSpace(ClientSecret) &&
Uri.TryCreate(CallbackUrl, UriKind.Absolute, out var callback) && callback.Scheme == Uri.UriSchemeHttps;
private string ClientId => _configuration["Google:ClientId"] ?? "";
private string ClientSecret => _configuration["Google:ClientSecret"] ?? "";
public string BuildAuthorizationUrl(ExternalAuthTransaction transaction)
{
var challenge = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(transaction.CodeVerifier ?? "")));
return AuthorizationEndpoint + "?" + Query(new Dictionary<string, string>
{
["client_id"] = ClientId,
["redirect_uri"] = CallbackUrl,
["response_type"] = "code",
["scope"] = "openid email profile",
["state"] = transaction.Id,
["nonce"] = transaction.Nonce ?? "",
["code_challenge"] = challenge,
["code_challenge_method"] = "S256",
["prompt"] = "select_account"
});
}
public async Task<ExternalProviderClaims?> VerifyCallbackAsync(
IQueryCollection query,
ExternalAuthTransaction transaction,
CancellationToken cancellationToken)
{
if (!IsConfigured || !string.IsNullOrWhiteSpace(query["error"]) ||
!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(query["state"].ToString()),
Encoding.UTF8.GetBytes(transaction.Id)))
return null;
var code = query["code"].ToString();
if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(transaction.CodeVerifier)) return null;
var client = _httpClients.CreateClient(nameof(GoogleExternalAuthProvider));
client.Timeout = TimeSpan.FromSeconds(15);
using var response = await client.PostAsync(TokenEndpoint, new FormUrlEncodedContent(new Dictionary<string, string>
{
["client_id"] = ClientId,
["client_secret"] = ClientSecret,
["code"] = code,
["code_verifier"] = transaction.CodeVerifier,
["grant_type"] = "authorization_code",
["redirect_uri"] = CallbackUrl
}), cancellationToken);
if (!response.IsSuccessStatusCode) return null;
using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
if (!payload.RootElement.TryGetProperty("id_token", out var idTokenElement)) return null;
var idToken = idTokenElement.GetString();
if (string.IsNullOrWhiteSpace(idToken)) return null;
var oidc = await _discovery.GetConfigurationAsync(cancellationToken);
var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
ClaimsPrincipal principal;
try
{
principal = handler.ValidateToken(idToken, new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuers = ["https://accounts.google.com", "accounts.google.com"],
ValidateAudience = true,
ValidAudience = ClientId,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKeys = oidc.SigningKeys,
ClockSkew = TimeSpan.FromMinutes(2)
}, out _);
}
catch (SecurityTokenException)
{
return null;
}
var subject = principal.FindFirstValue("sub");
var nonce = principal.FindFirstValue("nonce");
var email = principal.FindFirstValue("email");
var verified = string.Equals(principal.FindFirstValue("email_verified"), "true",
StringComparison.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(subject) || string.IsNullOrWhiteSpace(nonce) ||
!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(nonce), Encoding.UTF8.GetBytes(transaction.Nonce ?? "")))
return null;
return new ExternalProviderClaims(subject, email, verified);
}
internal static string NewVerifier() => Base64Url(RandomNumberGenerator.GetBytes(48));
internal static string NewNonce() => Base64Url(RandomNumberGenerator.GetBytes(32));
private static string Query(Dictionary<string, string> values) => string.Join("&", values.Select(pair =>
$"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"));
private static string Base64Url(byte[] bytes) =>
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
@@ -0,0 +1,164 @@
using MongoDB.Bson;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class ExternalIdentityStore(MongoDatabaseProvider mongo, AuthUserStore users)
{
private readonly IMongoCollection<ExternalIdentity> _identities =
mongo.Database.GetCollection<ExternalIdentity>("ExternalIdentities");
public async Task<ExternalIdentity?> GetAsync(string provider, string subject) =>
await _identities.Find(value => value.Provider == provider && value.Subject == subject)
.FirstOrDefaultAsync();
public async Task<ExternalIdentity?> GetForUserAsync(string userId, string provider) =>
await _identities.Find(value => value.UserId == userId && value.Provider == provider)
.FirstOrDefaultAsync();
public async Task<List<string>> ProvidersForUserAsync(string userId) =>
await _identities.Find(value => value.UserId == userId)
.Project(value => value.Provider).ToListAsync();
public async Task TouchAsync(string id) =>
await _identities.UpdateOneAsync(value => value.Id == id,
Builders<ExternalIdentity>.Update.Set(value => value.LastUsedUtc, DateTime.UtcNow));
public async Task<bool> TryLinkAsync(
string userId,
string provider,
ExternalProviderClaims claims)
{
var bySubject = await GetAsync(provider, claims.Subject);
if (bySubject is not null) return bySubject.UserId == userId;
var byUser = await GetForUserAsync(userId, provider);
if (byUser is not null) return byUser.Subject == claims.Subject;
var now = DateTime.UtcNow;
try
{
await _identities.InsertOneAsync(new ExternalIdentity
{
UserId = userId,
Provider = provider,
Subject = claims.Subject,
Email = claims.Email,
EmailVerified = claims.EmailVerified,
CreatedUtc = now,
LastUsedUtc = now
});
if (provider == ExternalAuthProviders.Steam &&
!await users.TrySetSteamIdAsync(userId, claims.Subject))
{
await _identities.DeleteOneAsync(value =>
value.UserId == userId && value.Provider == provider && value.Subject == claims.Subject);
return false;
}
return true;
}
catch (MongoWriteException exception) when (
exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
return false;
}
}
public async Task BackfillSteamAsync(ILogger logger)
{
var candidates = await users.Collection.Find(user => user.SteamId != null && user.SteamId != "")
.ToListAsync();
var inserted = 0;
foreach (var user in candidates)
{
var filter = Builders<ExternalIdentity>.Filter.Eq(value => value.Provider, ExternalAuthProviders.Steam) &
Builders<ExternalIdentity>.Filter.Eq(value => value.Subject, user.SteamId!);
var update = Builders<ExternalIdentity>.Update
.SetOnInsert(value => value.UserId, user.Id)
.SetOnInsert(value => value.Provider, ExternalAuthProviders.Steam)
.SetOnInsert(value => value.Subject, user.SteamId!)
.SetOnInsert(value => value.CreatedUtc, user.CreatedUtc ?? DateTime.UtcNow)
.SetOnInsert(value => value.LastUsedUtc, DateTime.UtcNow);
var result = await _identities.UpdateOneAsync(filter, update, new UpdateOptions { IsUpsert = true });
if (result.UpsertedId is not null) inserted++;
var identity = await _identities.Find(filter).FirstAsync();
if (identity.UserId != user.Id)
throw new InvalidOperationException(
$"Steam identity {user.SteamId} is associated with conflicting users.");
}
logger.LogInformation("External identity migration found {Candidates} Steam users and inserted {Inserted}",
candidates.Count, inserted);
}
public async Task EnsureIndexesAsync()
{
await _identities.Indexes.CreateManyAsync([
new CreateIndexModel<ExternalIdentity>(
Builders<ExternalIdentity>.IndexKeys.Ascending(value => value.Provider)
.Ascending(value => value.Subject),
new CreateIndexOptions { Unique = true, Name = "external_identity_provider_subject" }),
new CreateIndexModel<ExternalIdentity>(
Builders<ExternalIdentity>.IndexKeys.Ascending(value => value.UserId)
.Ascending(value => value.Provider),
new CreateIndexOptions { Unique = true, Name = "external_identity_user_provider" }),
new CreateIndexModel<ExternalIdentity>(
Builders<ExternalIdentity>.IndexKeys.Ascending(value => value.UserId),
new CreateIndexOptions { Name = "external_identity_user" })
]);
}
internal IMongoCollection<ExternalIdentity> Collection => _identities;
}
public sealed class ExternalAuthTransactionStore(MongoDatabaseProvider mongo)
{
private readonly IMongoCollection<ExternalAuthTransaction> _transactions =
mongo.Database.GetCollection<ExternalAuthTransaction>("ExternalAuthTransactions");
public Task CreateAsync(ExternalAuthTransaction transaction) => _transactions.InsertOneAsync(transaction);
public async Task<ExternalAuthTransaction?> GetAsync(string id) =>
await _transactions.Find(value => value.Id == id).FirstOrDefaultAsync();
public async Task<bool> MarkVerifiedAsync(
string id,
ExternalProviderClaims claims,
string status,
string? userId,
string? error = null)
{
var filter = Builders<ExternalAuthTransaction>.Filter.Eq(value => value.Id, id) &
Builders<ExternalAuthTransaction>.Filter.Eq(value => value.Status, "pending") &
Builders<ExternalAuthTransaction>.Filter.Gt(value => value.ExpiresUtc, DateTime.UtcNow);
var update = Builders<ExternalAuthTransaction>.Update
.Set(value => value.Subject, claims.Subject)
.Set(value => value.ProviderEmail, claims.Email)
.Set(value => value.EmailVerified, claims.EmailVerified)
.Set(value => value.Status, status)
.Set(value => value.UserId, userId)
.Set(value => value.Error, error);
return (await _transactions.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
public async Task<bool> MarkCompleteAsync(string id, string subject, string userId)
{
var filter = Builders<ExternalAuthTransaction>.Filter.Eq(value => value.Id, id) &
Builders<ExternalAuthTransaction>.Filter.Eq(value => value.Status, "requires_account") &
Builders<ExternalAuthTransaction>.Filter.Eq(value => value.Subject, subject) &
Builders<ExternalAuthTransaction>.Filter.Gt(value => value.ExpiresUtc, DateTime.UtcNow);
var update = Builders<ExternalAuthTransaction>.Update
.Set(value => value.Status, "complete")
.Set(value => value.UserId, userId);
return (await _transactions.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
public Task DeleteForUserAsync(string userId) =>
_transactions.DeleteManyAsync(value => value.UserId == userId);
public Task EnsureIndexesAsync() => _transactions.Indexes.CreateOneAsync(
new CreateIndexModel<ExternalAuthTransaction>(
Builders<ExternalAuthTransaction>.IndexKeys.Ascending(value => value.ExpiresUtc),
new CreateIndexOptions { ExpireAfter = TimeSpan.Zero, Name = "external_auth_expiry" }));
internal IMongoCollection<ExternalAuthTransaction> Collection => _transactions;
}
@@ -15,7 +15,8 @@ public sealed class UserProfileStore
"QuestionAttempts", "UserRatings", "QuestionBookmarks", "UserNotepads",
"ReviewQueueItems", "QuestionReviewAttempts", "ReviewQueueBackfillStates",
"CcsAttempts", "CcsUserRatings", "CcsRatedCaseResults", "ExamSimulations",
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles"
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles",
"ExternalIdentities", "ExternalAuthTransactions"
];
public UserProfileStore(MongoDatabaseProvider mongo)