using System.Text.Json; using Application.Abstractions.YouTube; using Microsoft.Extensions.Logging; namespace Infrastructure.Authentication; internal sealed class GoogleOAuthService( HttpClient httpClient, ILogger logger ) : IGoogleOAuthService { private const string AuthEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"; private const string TokenEndpoint = "https://oauth2.googleapis.com/token"; // ClientId/Secret은 호출자가 DB Config에서 가져와 전달하거나, // ExchangeCodeAsync 내부에서 Config를 조회하도록 확장 가능 // 현재는 HttpClient DI 시 BaseAddress 없이 사용 public string GetAuthorizationUrl(string state, string redirectUri, string[] scopes) { var scope = string.Join(" ", scopes); var parameters = new Dictionary { ["response_type"] = "code", ["access_type"] = "offline", // refresh_token 획득을 위해 ["prompt"] = "consent", // 항상 동의 화면 (refresh_token 보장) ["state"] = state, ["redirect_uri"] = redirectUri, ["scope"] = scope }; var query = string.Join("&", parameters.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); // client_id는 프론트엔드에서 URL에 추가 (DB Config에서 조회) return $"{AuthEndpoint}?{query}"; } public async Task ExchangeCodeAsync(string code, string redirectUri, string clientId, string clientSecret, CancellationToken ct) { try { var content = new FormUrlEncodedContent(new Dictionary { ["code"] = code, ["redirect_uri"] = redirectUri, ["grant_type"] = "authorization_code", ["client_id"] = clientId, ["client_secret"] = clientSecret }); var response = await httpClient.PostAsync(TokenEndpoint, content, ct); if (!response.IsSuccessStatusCode) { var errorBody = await response.Content.ReadAsStringAsync(ct); logger.LogWarning("Google OAuth code exchange failed: {StatusCode} {Body}", response.StatusCode, errorBody); return null; } var json = await response.Content.ReadAsStringAsync(ct); return ParseTokenResponse(json); } catch (Exception ex) { logger.LogError(ex, "Google OAuth code exchange error"); return null; } } public async Task RefreshTokenAsync(string refreshToken, CancellationToken ct) { try { var content = new FormUrlEncodedContent(new Dictionary { ["refresh_token"] = refreshToken, ["grant_type"] = "refresh_token" }); var response = await httpClient.PostAsync(TokenEndpoint, content, ct); if (!response.IsSuccessStatusCode) { var errorBody = await response.Content.ReadAsStringAsync(ct); logger.LogWarning("Google OAuth token refresh failed: {StatusCode} {Body}", response.StatusCode, errorBody); return null; } var json = await response.Content.ReadAsStringAsync(ct); return ParseTokenResponse(json); } catch (Exception ex) { logger.LogError(ex, "Google OAuth token refresh error"); return null; } } public async Task RevokeTokenAsync(string token, CancellationToken ct) { try { var content = new FormUrlEncodedContent(new Dictionary { ["token"] = token }); var response = await httpClient.PostAsync("https://oauth2.googleapis.com/revoke", content, ct); if (!response.IsSuccessStatusCode) { var errorBody = await response.Content.ReadAsStringAsync(ct); logger.LogWarning("Google OAuth revoke failed: {StatusCode} {Body}", response.StatusCode, errorBody); } } catch (Exception ex) { logger.LogWarning(ex, "Google OAuth revoke error (ignored)"); } } private static GoogleOAuthTokens? ParseTokenResponse(string json) { var doc = JsonSerializer.Deserialize(json); if (!doc.TryGetProperty("access_token", out var accessTokenProp)) { return null; } var accessToken = accessTokenProp.GetString()!; var refreshToken = doc.TryGetProperty("refresh_token", out var rtProp) ? rtProp.GetString() : null; var expiresIn = doc.TryGetProperty("expires_in", out var expProp) ? expProp.GetInt32() : 3600; var scope = doc.TryGetProperty("scope", out var scopeProp) ? scopeProp.GetString() ?? "" : ""; return new GoogleOAuthTokens( accessToken, refreshToken, DateTime.UtcNow.AddSeconds(expiresIn - 60), // 60초 여유 scope.Split(' ', StringSplitOptions.RemoveEmptyEntries) ); } }