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 providerServices, ExternalIdentityStore identities, ExternalAuthTransactionStore transactions, AuthUserStore users, JwtTokenService tokens, LegalPolicy legal, IConfiguration configuration, ILogger logger) : ControllerBase { private readonly Dictionary _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 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 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 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 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 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 = $$""" RanaZe {{safeProvider}} sign-in

{{safeProvider}} sign-in {{(success ? "complete" : "failed")}}

{{safeMessage}}

"""; return Content(html, "text/html; charset=utf-8"); } private static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); }