using System.Text.Json; using Application.Abstractions.Authentication; using Microsoft.Extensions.Logging; namespace Infrastructure.Authentication; /// /// 네이버 로그인 (OAuth 2.0 authorization code). OIDC(id_token) 미지원이라 code → 토큰 → 프로필 API 경로 사용. /// state 는 스펙상 필수 (CSRF 방어) — 호출자(핸들러)가 검증하고 토큰 교환에도 전달한다. /// email 은 "연락처 이메일"이라 타사 주소 가능 + 검증 플래그 없음 → EmailVerified 항상 false (d5 §③). /// public sealed class NaverAuthProvider( HttpClient httpClient, ILogger logger ) : ISocialAuthProvider { public const string ProviderName = "Naver"; private const string AuthEndpoint = "https://nid.naver.com/oauth2.0/authorize"; private const string TokenEndpoint = "https://nid.naver.com/oauth2.0/token"; private const string UserInfoEndpoint = "https://openapi.naver.com/v1/nid/me"; 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 }; 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, ["client_secret"] = clientSecret ?? string.Empty, ["code"] = code, ["redirect_uri"] = redirectUri }; if (!string.IsNullOrWhiteSpace(state)) { form["state"] = state; } var response = await httpClient.PostAsync(TokenEndpoint, new FormUrlEncodedContent(form), ct); var json = await response.Content.ReadAsStringAsync(ct); // 네이버 토큰 엔드포인트는 오류도 200 으로 내려줄 수 있음 (error 필드) → 파서가 함께 거른다 if (!response.IsSuccessStatusCode) { logger.LogWarning("Naver OAuth code exchange failed: {StatusCode} {Body}", response.StatusCode, json); return null; } var tokens = ParseTokens(json); if (tokens is null) { logger.LogWarning("Naver OAuth token response invalid: {Body}", json); } return tokens; } catch (Exception ex) { logger.LogError(ex, "Naver 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("Naver user info failed: {StatusCode} {Body}", response.StatusCode, json); return null; } var userInfo = ParseUserInfo(json); if (userInfo is null) { logger.LogWarning("Naver user info response invalid: {Body}", json); } return userInfo; } catch (Exception ex) { logger.LogError(ex, "Naver user info error"); return null; } } /// 토큰 응답 파싱 — 네이버는 expires_in 을 문자열("3600")로 내려준다 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 = ReadExpiresIn(doc); return new SocialTokens( accessToken, refreshToken, DateTime.UtcNow.AddSeconds(expiresIn - 60), // 60초 여유 [] ); } /// 프로필 응답 파싱 — resultcode "00" 외에는 실패. id 는 앱 단위 유니크 문자열(≤64자) public static SocialUserInfo? ParseUserInfo(string json) { var doc = JsonSerializer.Deserialize(json); if (doc.ValueKind != JsonValueKind.Object || !doc.TryGetProperty("resultcode", out var resultCodeProp) || resultCodeProp.GetString() != "00") { return null; } if (!doc.TryGetProperty("response", out var res) || res.ValueKind != JsonValueKind.Object) { return null; } if (!res.TryGetProperty("id", out var idProp) || idProp.GetString() is not { Length: > 0 } id) { return null; } var email = res.TryGetProperty("email", out var emailProp) ? emailProp.GetString() : null; var nickname = res.TryGetProperty("nickname", out var nickProp) ? nickProp.GetString() : null; var profileImage = res.TryGetProperty("profile_image", out var imgProp) ? imgProp.GetString() : null; // 네이버 email 은 연락처 이메일 (타사 주소 가능, 검증 플래그 없음) → 항상 미검증 취급 return new SocialUserInfo(id, email, EmailVerified: false, nickname, profileImage); } internal static int ReadExpiresIn(JsonElement doc) { if (!doc.TryGetProperty("expires_in", out var expProp)) { return 3600; } return expProp.ValueKind switch { JsonValueKind.Number => expProp.GetInt32(), JsonValueKind.String when int.TryParse(expProp.GetString(), out var parsed) => parsed, _ => 3600 }; } }