using MongoDB.Bson; using MongoDB.Driver; using QuestionsApi.Models; namespace QuestionsApi.Services; public sealed class ExternalIdentityStore(MongoDatabaseProvider mongo, AuthUserStore users) { private readonly IMongoCollection _identities = mongo.Database.GetCollection("ExternalIdentities"); public async Task GetAsync(string provider, string subject) => await _identities.Find(value => value.Provider == provider && value.Subject == subject) .FirstOrDefaultAsync(); public async Task GetForUserAsync(string userId, string provider) => await _identities.Find(value => value.UserId == userId && value.Provider == provider) .FirstOrDefaultAsync(); public async Task> 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.Update.Set(value => value.LastUsedUtc, DateTime.UtcNow)); public async Task 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.Filter.Eq(value => value.Provider, ExternalAuthProviders.Steam) & Builders.Filter.Eq(value => value.Subject, user.SteamId!); var update = Builders.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( Builders.IndexKeys.Ascending(value => value.Provider) .Ascending(value => value.Subject), new CreateIndexOptions { Unique = true, Name = "external_identity_provider_subject" }), new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.UserId) .Ascending(value => value.Provider), new CreateIndexOptions { Unique = true, Name = "external_identity_user_provider" }), new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.UserId), new CreateIndexOptions { Name = "external_identity_user" }) ]); } internal IMongoCollection Collection => _identities; } public sealed class ExternalAuthTransactionStore(MongoDatabaseProvider mongo) { private readonly IMongoCollection _transactions = mongo.Database.GetCollection("ExternalAuthTransactions"); public Task CreateAsync(ExternalAuthTransaction transaction) => _transactions.InsertOneAsync(transaction); public async Task GetAsync(string id) => await _transactions.Find(value => value.Id == id).FirstOrDefaultAsync(); public async Task MarkVerifiedAsync( string id, ExternalProviderClaims claims, string status, string? userId, string? error = null) { var filter = Builders.Filter.Eq(value => value.Id, id) & Builders.Filter.Eq(value => value.Status, "pending") & Builders.Filter.Gt(value => value.ExpiresUtc, DateTime.UtcNow); var update = Builders.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 MarkCompleteAsync(string id, string subject, string userId) { var filter = Builders.Filter.Eq(value => value.Id, id) & Builders.Filter.Eq(value => value.Status, "requires_account") & Builders.Filter.Eq(value => value.Subject, subject) & Builders.Filter.Gt(value => value.ExpiresUtc, DateTime.UtcNow); var update = Builders.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( Builders.IndexKeys.Ascending(value => value.ExpiresUtc), new CreateIndexOptions { ExpireAfter = TimeSpan.Zero, Name = "external_auth_expiry" })); internal IMongoCollection Collection => _transactions; }