using System.Text.Json; using Application.Abstractions.Authentication; using Microsoft.Extensions.Logging; namespace Infrastructure.Authentication; /// /// 카카오 로그인 (OAuth 2.0 authorization code). client_id 는 REST API 키. /// account_email 은 기본 선택동의 (필수동의 전환은 비즈앱 필요 — d0 §5-⑦ 추후) → email null 가능. /// client_secret 은 콘솔에서 활성화한 경우에만 필수 — 미설정(빈 값)이면 전송하지 않는다. /// public sealed class KakaoAuthProvider( HttpClient httpClient, ILogger logger ) : ISocialAuthProvider { public const string ProviderName = "Kakao"; private const string AuthEndpoint = "https://kauth.kakao.com/oauth/authorize"; private const string TokenEndpoint = "https://kauth.kakao.com/oauth/token"; private const string UserInfoEndpoint = "https://kapi.kakao.com/v2/user/me"; private const string Scope = "account_email profile_nickname profile_image"; public string Provider => ProviderName; public string GetAuthorizationUrl(string clientId, string redirectUri, string state) { var parameters = new Dictionary { ["response_type"] = "code", ["client_id"] = clientId, ["redirect_uri"] = redirectUri, ["state"] = state, // 스펙상 선택이나 CSRF 방어 위해 항상 전송 (d5 §③) ["scope"] = Scope }; var query = string.Join("&", parameters.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); return $"{AuthEndpoint}?{query}"; } public async Task ExchangeCodeAsync(string code, string redirectUri, string? state, string clientId, string? clientSecret, CancellationToken ct) { try { var form = new Dictionary { ["grant_type"] = "authorization_code", ["client_id"] = clientId, ["redirect_uri"] = redirectUri, ["code"] = code }; if (!string.IsNullOrWhiteSpace(clientSecret)) { form["client_secret"] = clientSecret; } var response = await httpClient.PostAsync(TokenEndpoint, new FormUrlEncodedContent(form), ct); var json = await response.Content.ReadAsStringAsync(ct); if (!response.IsSuccessStatusCode) { logger.LogWarning("Kakao OAuth code exchange failed: {StatusCode} {Body}", response.StatusCode, json); return null; } var tokens = ParseTokens(json); if (tokens is null) { logger.LogWarning("Kakao OAuth token response invalid: {Body}", json); } return tokens; } catch (Exception ex) { logger.LogError(ex, "Kakao OAuth code exchange error"); return null; } } public async Task GetUserInfoAsync(string accessToken, CancellationToken ct) { try { using var request = new HttpRequestMessage(HttpMethod.Get, UserInfoEndpoint); request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); var response = await httpClient.SendAsync(request, ct); var json = await response.Content.ReadAsStringAsync(ct); if (!response.IsSuccessStatusCode) { logger.LogWarning("Kakao user info failed: {StatusCode} {Body}", response.StatusCode, json); return null; } var userInfo = ParseUserInfo(json); if (userInfo is null) { logger.LogWarning("Kakao user info response invalid: {Body}", json); } return userInfo; } catch (Exception ex) { logger.LogError(ex, "Kakao user info error"); return null; } } /// 토큰 응답 파싱 — scope 는 공백 구분 문자열 public static SocialTokens? ParseTokens(string json) { var doc = JsonSerializer.Deserialize(json); if (doc.ValueKind != JsonValueKind.Object || doc.TryGetProperty("error", out _)) { return null; } if (!doc.TryGetProperty("access_token", out var accessTokenProp) || accessTokenProp.GetString() is not { Length: > 0 } accessToken) { return null; } var refreshToken = doc.TryGetProperty("refresh_token", out var rtProp) ? rtProp.GetString() : null; var expiresIn = NaverAuthProvider.ReadExpiresIn(doc); var scope = doc.TryGetProperty("scope", out var scopeProp) ? scopeProp.GetString() ?? string.Empty : string.Empty; return new SocialTokens( accessToken, refreshToken, DateTime.UtcNow.AddSeconds(expiresIn - 60), // 60초 여유 scope.Split(' ', StringSplitOptions.RemoveEmptyEntries) ); } /// /// 프로필 응답 파싱 — id 는 int64 (문자열화하여 ProviderUserKey 로 사용). /// email 은 선택동의 미동의 시 부재 → null. EmailVerified 는 is_email_verified 그대로. /// public static SocialUserInfo? ParseUserInfo(string json) { var doc = JsonSerializer.Deserialize(json); if (doc.ValueKind != JsonValueKind.Object || !doc.TryGetProperty("id", out var idProp) || idProp.ValueKind != JsonValueKind.Number) { return null; } var id = idProp.GetInt64().ToString(); string? email = null; var emailVerified = false; string? nickname = null; string? profileImage = null; if (doc.TryGetProperty("kakao_account", out var account) && account.ValueKind == JsonValueKind.Object) { if (account.TryGetProperty("email", out var emailProp)) { email = emailProp.GetString(); } if (account.TryGetProperty("is_email_verified", out var verifiedProp)) { emailVerified = verifiedProp.ValueKind == JsonValueKind.True; } if (account.TryGetProperty("profile", out var profile) && profile.ValueKind == JsonValueKind.Object) { nickname = profile.TryGetProperty("nickname", out var nickProp) ? nickProp.GetString() : null; profileImage = profile.TryGetProperty("profile_image_url", out var imgProp) ? imgProp.GetString() : null; } } return new SocialUserInfo(id, email, email is not null && emailVerified, nickname, profileImage); } }