using System.Net.Http; using System.Text; using System.Text.Json; namespace AxCopilot.Services; /// /// IBM Cloud IAM 액세스 토큰 발급 및 캐싱 서비스. /// API 키를 IAM 토큰으로 교환한 뒤 Bearer 토큰으로 재사용합니다. /// internal sealed class IbmIamTokenService { private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) }; private static readonly Dictionary _cache = new(); private static readonly object _lock = new(); private const string DefaultIamUrl = "https://iam.cloud.ibm.com/identity/token"; public static async Task GetTokenAsync(string apiKey, string? iamUrl = null, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(apiKey)) return null; var tokenUrl = string.IsNullOrWhiteSpace(iamUrl) ? DefaultIamUrl : iamUrl.Trim(); var cacheKey = $"{tokenUrl}|{apiKey}"; lock (_lock) { if (_cache.TryGetValue(cacheKey, out var cached) && cached.Expiry > DateTime.UtcNow.AddMinutes(1)) return cached.Token; } try { using var req = new HttpRequestMessage(HttpMethod.Post, tokenUrl); req.Content = new StringContent( $"grant_type=urn:ibm:params:oauth:grant-type:apikey&apikey={Uri.EscapeDataString(apiKey)}", Encoding.UTF8, "application/x-www-form-urlencoded"); req.Headers.Accept.ParseAdd("application/json"); using var resp = await _http.SendAsync(req, ct); if (!resp.IsSuccessStatusCode) { var errBody = await resp.Content.ReadAsStringAsync(ct); LogService.Warn($"IBM IAM 토큰 발급 실패: {resp.StatusCode} - {errBody}"); return null; } var json = await resp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(json); if (!doc.RootElement.TryGetProperty("access_token", out var tokenProp)) { LogService.Warn("IBM IAM 응답에 access_token 필드가 없습니다."); return null; } var token = tokenProp.GetString(); if (string.IsNullOrWhiteSpace(token)) return null; var expiry = DateTime.UtcNow.AddMinutes(55); if (doc.RootElement.TryGetProperty("expiration", out var expirationProp) && expirationProp.TryGetInt64(out var expirationEpoch)) { expiry = DateTimeOffset.FromUnixTimeSeconds(expirationEpoch).UtcDateTime; } else if (doc.RootElement.TryGetProperty("expires_in", out var expiresInProp) && expiresInProp.TryGetInt64(out var expiresInSeconds)) { expiry = DateTime.UtcNow.AddSeconds(expiresInSeconds); } lock (_lock) { _cache[cacheKey] = (token, expiry); } LogService.Info($"IBM IAM 토큰 발급 완료: {tokenUrl} (만료: {expiry:yyyy-MM-dd HH:mm} UTC)"); return token; } catch (Exception ex) { LogService.Error($"IBM IAM 토큰 발급 오류: {ex.Message}"); return null; } } public static void InvalidateToken(string apiKey, string? iamUrl = null) { if (string.IsNullOrWhiteSpace(apiKey)) return; var tokenUrl = string.IsNullOrWhiteSpace(iamUrl) ? DefaultIamUrl : iamUrl.Trim(); var cacheKey = $"{tokenUrl}|{apiKey}"; lock (_lock) { _cache.Remove(cacheKey); } } public static void ClearAllTokens() { lock (_lock) { _cache.Clear(); } } }