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
};
}
}