using System.Text; using System.Text.Json; using System.Windows; using AxCopilot.SDK; using AxCopilot.Services; using AxCopilot.Themes; namespace AxCopilot.Handlers; /// /// L11-2: JWT 토큰 디코더 핸들러. "jwt" 프리픽스로 사용합니다. /// /// 예: jwt → 클립보드의 JWT 자동 분석 /// jwt eyJhbGci... → 토큰 직접 입력 /// jwt header → 클립보드 JWT 헤더만 표시 /// jwt payload → 클립보드 JWT 페이로드만 표시 /// Enter → 결과를 클립보드에 복사. /// 주의: 서명(signature) 검증은 수행하지 않음 — 분석 전용. /// public class JwtHandler : IActionHandler { public string? Prefix => "jwt"; public PluginMetadata Metadata => new( "JWT", "JWT 토큰 디코더 — 헤더 · 페이로드 · 만료일 분석", "1.0", "AX"); public Task> GetItemsAsync(string query, CancellationToken ct) { var q = query.Trim(); var items = new List(); if (string.IsNullOrWhiteSpace(q)) { var clip = GetClipboard(); if (LooksJwt(clip)) { items.AddRange(DecodeJwt(clip, "all")); } else { items.Add(new LauncherItem("JWT 디코더", "JWT 토큰을 클립보드에 복사하거나 직접 입력하세요", null, null, Symbol: "\uE72E")); items.Add(new LauncherItem("jwt eyJ…", "토큰 직접 입력", null, null, Symbol: "\uE72E")); items.Add(new LauncherItem("jwt header", "헤더만 표시", null, null, Symbol: "\uE72E")); items.Add(new LauncherItem("jwt payload", "페이로드만 표시", null, null, Symbol: "\uE72E")); } return Task.FromResult>(items); } var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); var sub = parts[0].ToLowerInvariant(); switch (sub) { case "header": { var src = GetTokenSource(parts); items.AddRange(DecodeJwt(src, "header")); break; } case "payload": case "claims": case "body": { var src = GetTokenSource(parts); items.AddRange(DecodeJwt(src, "payload")); break; } default: { // 토큰 자체 입력 (eyJ로 시작) var token = LooksJwt(q) ? q : GetClipboard(); if (!LooksJwt(token)) { items.Add(new LauncherItem("JWT 형식 아님", "eyJ…로 시작하는 JWT 토큰을 입력하거나 클립보드에 복사하세요", null, null, Symbol: "\uE783")); } else { items.AddRange(DecodeJwt(token, "all")); } break; } } return Task.FromResult>(items); } public Task ExecuteAsync(LauncherItem item, CancellationToken ct) { if (item.Data is ("copy", string text)) { try { System.Windows.Application.Current.Dispatcher.Invoke( () => Clipboard.SetText(text)); NotificationService.Notify("JWT", "클립보드에 복사했습니다."); } catch { /* 비핵심 */ } } return Task.CompletedTask; } // ── JWT 디코딩 ──────────────────────────────────────────────────────────── private static IEnumerable DecodeJwt(string token, string mode) { if (!LooksJwt(token)) { yield return new LauncherItem("JWT 없음", "클립보드에 eyJ…로 시작하는 JWT가 없습니다", null, null, Symbol: "\uE783"); yield break; } var parts = token.Split('.'); if (parts.Length < 2) { yield return new LauncherItem("형식 오류", "JWT는 최소 2개의 점(.)으로 구성됩니다", null, null, Symbol: "\uE783"); yield break; } // 헤더 디코딩 if (mode is "header" or "all") { var headerJson = TryDecodeBase64Url(parts[0]); if (headerJson != null) { var pretty = TryPrettyJson(headerJson) ?? headerJson; yield return new LauncherItem("─ 헤더 ─", "", null, null, Symbol: "\uE72E"); foreach (var item in ExtractJsonFields(headerJson, "헤더")) yield return item; yield return new LauncherItem("헤더 JSON", "전체 복사", null, ("copy", pretty), Symbol: "\uE72E"); } } // 페이로드 디코딩 if (mode is "payload" or "all") { if (parts.Length < 2) { yield return new LauncherItem("페이로드 없음", "JWT에 페이로드가 없습니다", null, null, Symbol: "\uE783"); yield break; } var payloadJson = TryDecodeBase64Url(parts[1]); if (payloadJson != null) { yield return new LauncherItem("─ 페이로드 ─", "", null, null, Symbol: "\uE72E"); // 만료일(exp) 특별 처리 var expItem = ExtractExpiry(payloadJson); if (expItem != null) yield return expItem; foreach (var item in ExtractJsonFields(payloadJson, "페이로드")) yield return item; var pretty = TryPrettyJson(payloadJson) ?? payloadJson; yield return new LauncherItem("페이로드 JSON", "전체 복사", null, ("copy", pretty), Symbol: "\uE72E"); } } // 서명 유무 if (mode == "all") { var hasSig = parts.Length >= 3 && !string.IsNullOrEmpty(parts[2]); yield return new LauncherItem( "서명", hasSig ? "있음 (검증 미지원 — 분석 전용)" : "없음 (alg:none)", null, null, Symbol: "\uE72E"); } } private static IEnumerable ExtractJsonFields(string json, string section) { JsonDocument doc; try { doc = JsonDocument.Parse(json); } catch { yield break; } using (doc) { foreach (var prop in doc.RootElement.EnumerateObject()) { var val = prop.Value.ValueKind switch { JsonValueKind.String => prop.Value.GetString() ?? "", JsonValueKind.Number => prop.Value.GetRawText(), JsonValueKind.True => "true", JsonValueKind.False => "false", JsonValueKind.Null => "null", _ => prop.Value.GetRawText(), }; // exp, iat, nbf 는 타임스탬프 → 날짜 변환해서 별도 표시 if (prop.Name is "exp" or "iat" or "nbf") { if (long.TryParse(val, out var ts)) { var dt = DateTimeOffset.FromUnixTimeSeconds(ts).ToLocalTime(); var label = prop.Name switch { "exp" => "만료(exp)", "iat" => "발급(iat)", _ => "유효 시작(nbf)" }; yield return new LauncherItem(label, dt.ToString("yyyy-MM-dd HH:mm:ss"), null, ("copy", dt.ToString("o")), Symbol: "\uE72E"); continue; } } var display = val.Length > 60 ? val[..60] + "…" : val; yield return new LauncherItem(prop.Name, display, null, ("copy", val), Symbol: "\uE72E"); } } } private static LauncherItem? ExtractExpiry(string payloadJson) { try { var doc = JsonDocument.Parse(payloadJson); if (!doc.RootElement.TryGetProperty("exp", out var expProp)) return null; if (!expProp.TryGetInt64(out var exp)) return null; var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var dt = DateTimeOffset.FromUnixTimeSeconds(exp).ToLocalTime(); var remain = exp - now; string status; if (remain < 0) status = $"만료됨 ({Math.Abs(remain / 60)}분 전)"; else if (remain < 60) status = $"곧 만료 ({remain}초 남음)"; else if (remain < 3600) status = $"유효 ({remain / 60}분 남음)"; else if (remain < 86400) status = $"유효 ({remain / 3600}시간 남음)"; else status = $"유효 ({remain / 86400}일 남음)"; return new LauncherItem( $"만료 상태: {status}", dt.ToString("yyyy-MM-dd HH:mm:ss"), null, null, Symbol: remain < 0 ? "\uE783" : "\uE73E"); } catch { return null; } } // ── 헬퍼 ───────────────────────────────────────────────────────────────── private static string? TryDecodeBase64Url(string input) { try { // Base64Url → Base64 var base64 = input.Replace('-', '+').Replace('_', '/'); var pad = (4 - base64.Length % 4) % 4; base64 += new string('=', pad); var bytes = Convert.FromBase64String(base64); return Encoding.UTF8.GetString(bytes); } catch { return null; } } private static string? TryPrettyJson(string json) { try { var doc = JsonDocument.Parse(json); return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true }); } catch { return null; } } private static bool LooksJwt(string? s) { if (string.IsNullOrWhiteSpace(s)) return false; s = s.Trim(); return s.StartsWith("eyJ", StringComparison.Ordinal) && s.Contains('.'); } private static string GetTokenSource(string[] parts) { // parts[1]에 토큰이 있으면 사용, 없으면 클립보드 if (parts.Length > 1 && LooksJwt(parts[1])) return parts[1]; return GetClipboard(); } private static string GetClipboard() { try { return System.Windows.Application.Current.Dispatcher.Invoke( () => Clipboard.ContainsText() ? Clipboard.GetText().Trim() : ""); } catch { return ""; } } }