GoogleOAuthService.cs 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. using System.Text.Json;
  2. using Application.Abstractions.YouTube;
  3. using Microsoft.Extensions.Logging;
  4. namespace Infrastructure.Authentication;
  5. internal sealed class GoogleOAuthService(
  6. HttpClient httpClient,
  7. ILogger<GoogleOAuthService> logger
  8. ) : IGoogleOAuthService
  9. {
  10. private const string AuthEndpoint = "https://accounts.google.com/o/oauth2/v2/auth";
  11. private const string TokenEndpoint = "https://oauth2.googleapis.com/token";
  12. // ClientId/Secret은 호출자가 DB Config에서 가져와 전달하거나,
  13. // ExchangeCodeAsync 내부에서 Config를 조회하도록 확장 가능
  14. // 현재는 HttpClient DI 시 BaseAddress 없이 사용
  15. public string GetAuthorizationUrl(string state, string redirectUri, string[] scopes)
  16. {
  17. var scope = string.Join(" ", scopes);
  18. var parameters = new Dictionary<string, string>
  19. {
  20. ["response_type"] = "code",
  21. ["access_type"] = "offline", // refresh_token 획득을 위해
  22. ["prompt"] = "consent", // 항상 동의 화면 (refresh_token 보장)
  23. ["state"] = state,
  24. ["redirect_uri"] = redirectUri,
  25. ["scope"] = scope
  26. };
  27. var query = string.Join("&", parameters.Select(kv =>
  28. $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
  29. // client_id는 프론트엔드에서 URL에 추가 (DB Config에서 조회)
  30. return $"{AuthEndpoint}?{query}";
  31. }
  32. public async Task<GoogleOAuthTokens?> ExchangeCodeAsync(string code, string redirectUri, string clientId, string clientSecret, CancellationToken ct)
  33. {
  34. try
  35. {
  36. var content = new FormUrlEncodedContent(new Dictionary<string, string>
  37. {
  38. ["code"] = code,
  39. ["redirect_uri"] = redirectUri,
  40. ["grant_type"] = "authorization_code",
  41. ["client_id"] = clientId,
  42. ["client_secret"] = clientSecret
  43. });
  44. var response = await httpClient.PostAsync(TokenEndpoint, content, ct);
  45. if (!response.IsSuccessStatusCode)
  46. {
  47. var errorBody = await response.Content.ReadAsStringAsync(ct);
  48. logger.LogWarning("Google OAuth code exchange failed: {StatusCode} {Body}", response.StatusCode, errorBody);
  49. return null;
  50. }
  51. var json = await response.Content.ReadAsStringAsync(ct);
  52. return ParseTokenResponse(json);
  53. }
  54. catch (Exception ex)
  55. {
  56. logger.LogError(ex, "Google OAuth code exchange error");
  57. return null;
  58. }
  59. }
  60. public async Task<GoogleOAuthTokens?> RefreshTokenAsync(string refreshToken, CancellationToken ct)
  61. {
  62. try
  63. {
  64. var content = new FormUrlEncodedContent(new Dictionary<string, string>
  65. {
  66. ["refresh_token"] = refreshToken,
  67. ["grant_type"] = "refresh_token"
  68. });
  69. var response = await httpClient.PostAsync(TokenEndpoint, content, ct);
  70. if (!response.IsSuccessStatusCode)
  71. {
  72. var errorBody = await response.Content.ReadAsStringAsync(ct);
  73. logger.LogWarning("Google OAuth token refresh failed: {StatusCode} {Body}", response.StatusCode, errorBody);
  74. return null;
  75. }
  76. var json = await response.Content.ReadAsStringAsync(ct);
  77. return ParseTokenResponse(json);
  78. }
  79. catch (Exception ex)
  80. {
  81. logger.LogError(ex, "Google OAuth token refresh error");
  82. return null;
  83. }
  84. }
  85. public async Task RevokeTokenAsync(string token, CancellationToken ct)
  86. {
  87. try
  88. {
  89. var content = new FormUrlEncodedContent(new Dictionary<string, string>
  90. {
  91. ["token"] = token
  92. });
  93. var response = await httpClient.PostAsync("https://oauth2.googleapis.com/revoke", content, ct);
  94. if (!response.IsSuccessStatusCode)
  95. {
  96. var errorBody = await response.Content.ReadAsStringAsync(ct);
  97. logger.LogWarning("Google OAuth revoke failed: {StatusCode} {Body}", response.StatusCode, errorBody);
  98. }
  99. }
  100. catch (Exception ex)
  101. {
  102. logger.LogWarning(ex, "Google OAuth revoke error (ignored)");
  103. }
  104. }
  105. private static GoogleOAuthTokens? ParseTokenResponse(string json)
  106. {
  107. var doc = JsonSerializer.Deserialize<JsonElement>(json);
  108. if (!doc.TryGetProperty("access_token", out var accessTokenProp))
  109. {
  110. return null;
  111. }
  112. var accessToken = accessTokenProp.GetString()!;
  113. var refreshToken = doc.TryGetProperty("refresh_token", out var rtProp) ? rtProp.GetString() : null;
  114. var expiresIn = doc.TryGetProperty("expires_in", out var expProp) ? expProp.GetInt32() : 3600;
  115. var scope = doc.TryGetProperty("scope", out var scopeProp) ? scopeProp.GetString() ?? "" : "";
  116. return new GoogleOAuthTokens(
  117. accessToken,
  118. refreshToken,
  119. DateTime.UtcNow.AddSeconds(expiresIn - 60), // 60초 여유
  120. scope.Split(' ', StringSplitOptions.RemoveEmptyEntries)
  121. );
  122. }
  123. }