using System.Net.Http.Headers; using System.Text; using System.Text.Json; using FlatRender.IdentitySvc.Domain.Entities; using FlatRender.IdentitySvc.Domain.Enums; using FlatRender.IdentitySvc.Infrastructure.Data; using FlatRender.IdentitySvc.Models.Responses; using Microsoft.AspNetCore.WebUtilities; using Microsoft.EntityFrameworkCore; namespace FlatRender.IdentitySvc.Application.Services; /// External OAuth (Google) — admin config + the authorization-code login flow. public class OAuthService( IdentityDbContext db, AuthService authService, IHttpClientFactory httpFactory, IConfiguration config) { private const string GoogleAuthEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"; private const string GoogleTokenEndpoint = "https://oauth2.googleapis.com/token"; private const string GoogleUserInfo = "https://www.googleapis.com/oauth2/v3/userinfo"; private string DefaultTenantSlug => config["DefaultTenantSlug"] ?? "flatrender"; // ── Config ─────────────────────────────────────────────────────────────── public async Task GetConfigAsync(string provider) => await db.OAuthConfigs.FindAsync(provider); public async Task UpsertConfigAsync(string provider, string? clientId, string? clientSecret, string? redirectUri, bool enabled) { var cfg = await db.OAuthConfigs.FindAsync(provider); if (cfg == null) { cfg = new OAuthConfig { Provider = provider }; db.OAuthConfigs.Add(cfg); } if (clientId != null) cfg.ClientId = clientId; // Keep the existing secret if the client sent it blank/masked. if (!string.IsNullOrEmpty(clientSecret) && !clientSecret.Contains('•')) cfg.ClientSecret = clientSecret; if (redirectUri != null) cfg.RedirectUri = redirectUri; cfg.Enabled = enabled; cfg.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return cfg; } // ── Google authorization-code flow ───────────────────────────────────────── public async Task BuildGoogleAuthUrlAsync(string returnUrl) { var cfg = await GetConfigAsync("google"); if (cfg is not { Enabled: true } || string.IsNullOrEmpty(cfg.ClientId) || string.IsNullOrEmpty(cfg.RedirectUri)) throw new InvalidOperationException("Google login is not configured"); var state = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(returnUrl)); var query = new Dictionary { ["client_id"] = cfg.ClientId, ["redirect_uri"] = cfg.RedirectUri, ["response_type"] = "code", ["scope"] = "openid email profile", ["state"] = state, ["access_type"] = "online", ["prompt"] = "select_account", }; return QueryHelpers.AddQueryString(GoogleAuthEndpoint, query); } public record GoogleResult(AuthTokensResponse Tokens, string ReturnUrl); public async Task HandleGoogleCallbackAsync(string code, string state, string? ipAddress) { var returnUrl = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(state)); var cfg = await GetConfigAsync("google") ?? throw new InvalidOperationException("Google login is not configured"); var http = httpFactory.CreateClient(); // 1) Exchange the authorization code for tokens. var tokenResp = await http.PostAsync(GoogleTokenEndpoint, new FormUrlEncodedContent(new Dictionary { ["code"] = code, ["client_id"] = cfg.ClientId ?? "", ["client_secret"] = cfg.ClientSecret ?? "", ["redirect_uri"] = cfg.RedirectUri ?? "", ["grant_type"] = "authorization_code", })); var tokenBody = await tokenResp.Content.ReadAsStringAsync(); if (!tokenResp.IsSuccessStatusCode) throw new InvalidOperationException($"Google token exchange failed: {tokenBody}"); using var tokenDoc = JsonDocument.Parse(tokenBody); var accessToken = tokenDoc.RootElement.GetProperty("access_token").GetString(); // 2) Fetch the user's profile. var infoReq = new HttpRequestMessage(HttpMethod.Get, GoogleUserInfo); infoReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); var infoResp = await http.SendAsync(infoReq); var infoBody = await infoResp.Content.ReadAsStringAsync(); if (!infoResp.IsSuccessStatusCode) throw new InvalidOperationException($"Google userinfo failed: {infoBody}"); using var infoDoc = JsonDocument.Parse(infoBody); var root = infoDoc.RootElement; var email = root.TryGetProperty("email", out var e) ? e.GetString() : null; var name = root.TryGetProperty("name", out var n) ? n.GetString() : null; var picture = root.TryGetProperty("picture", out var p) ? p.GetString() : null; if (string.IsNullOrEmpty(email)) throw new InvalidOperationException("Google account has no email"); email = email.ToLowerInvariant(); // 3) Find or create the user in the default tenant. var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.Slug == DefaultTenantSlug && t.DeletedAt == null) ?? await db.Tenants.FirstOrDefaultAsync(t => t.DeletedAt == null) ?? throw new InvalidOperationException("No tenant configured"); var user = await db.Users.FirstOrDefaultAsync(u => u.TenantId == tenant.Id && u.Email == email && u.DeletedAt == null); if (user == null) { user = new User { TenantId = tenant.Id, Email = email, FullName = name, AvatarUrl = picture, EmailVerified = true, RegisterMode = RegisterMode.Google, RegisterDate = DateTime.UtcNow, }; db.Users.Add(user); await db.SaveChangesAsync(); } else if (!user.EmailVerified) { user.EmailVerified = true; await db.SaveChangesAsync(); } var tokens = await authService.IssueOAuthSessionAsync(user, tenant, "Google", ipAddress); return new GoogleResult(tokens, returnUrl); } }