Forge
csharp3407750f
1using System.Diagnostics;
2using System.Net;
3using System.Net.Http.Json;
4using System.Security.Cryptography;
5using System.Text;
6using System.Text.Json;
7
8namespace AgentForge.Credentials;
9
10/// <summary>PKCE OAuth loopback client (FORGE-ADR-0011, CIDE Intercom pattern).</summary>
11public sealed class ForgeOAuthLoopbackClient
12{
13 private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
14
15 public async Task<(bool Ok, string? Token, string? TokenName, string Error)> ConnectAsync(
16 string baseUrl,
17 string provider,
18 CancellationToken cancellationToken = default)
19 {
20 var normalized = baseUrl.Trim().TrimEnd('/');
21 using var http = new HttpClient { BaseAddress = new Uri(normalized + "/"), Timeout = TimeSpan.FromMinutes(6) };
22
23 var verifier = GenerateCodeVerifier();
24 var challenge = ComputeS256Challenge(verifier);
25 var port = FindFreePort();
26 var redirectUri = $"http://127.0.0.1:{port}/callback";
27
28 using var listener = new HttpListener();
29 listener.Prefixes.Add($"http://127.0.0.1:{port}/");
30 listener.Start();
31
32 var loginUrl =
33 $"{normalized}/api/v1/auth/login" +
34 $"?provider={Uri.EscapeDataString(provider)}" +
35 $"&redirect_uri={Uri.EscapeDataString(redirectUri)}" +
36 $"&code_challenge={Uri.EscapeDataString(challenge)}&code_challenge_method=S256" +
37 "&scopes=read%2Cwrite%2Caccept_merge";
38
39 try
40 {
41 Process.Start(new ProcessStartInfo(loginUrl) { UseShellExecute = true });
42 }
43 catch (Exception ex)
44 {
45 listener.Stop();
46 return (false, null, null, "Не удалось открыть браузер: " + ex.Message);
47 }
48
49 var waitTask = WaitForCallbackAsync(listener, cancellationToken);
50 var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromMinutes(5), cancellationToken)).ConfigureAwait(false);
51 listener.Stop();
52
53 if (completed != waitTask)
54 return (false, null, null, "Таймаут OAuth (5 мин).");
55
56 var (code, state, error) = await waitTask.ConfigureAwait(false);
57 if (!string.IsNullOrEmpty(error))
58 return (false, null, null, error);
59 if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
60 return (false, null, null, "В callback нет code/state.");
61
62 using var tokenResponse = await http.PostAsJsonAsync(
63 "/api/v1/auth/token",
64 new
65 {
66 grant_type = "authorization_code",
67 code,
68 state,
69 code_verifier = verifier,
70 redirect_uri = redirectUri,
71 },
72 cancellationToken).ConfigureAwait(false);
73
74 if (!tokenResponse.IsSuccessStatusCode)
75 return (false, null, null, await tokenResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false));
76
77 using var doc = JsonDocument.Parse(await tokenResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false));
78 var token = doc.RootElement.GetProperty("access_token").GetString();
79 var tokenName = doc.RootElement.TryGetProperty("token_name", out var nameEl) ? nameEl.GetString() : null;
80 return string.IsNullOrEmpty(token)
81 ? (false, null, null, "Пустой access_token.")
82 : (true, token, tokenName, "");
83 }
84
85 private static async Task<(string? Code, string? State, string? Error)> WaitForCallbackAsync(
86 HttpListener listener,
87 CancellationToken cancellationToken)
88 {
89 while (!cancellationToken.IsCancellationRequested)
90 {
91 var ctx = await listener.GetContextAsync().ConfigureAwait(false);
92 var query = ParseQuery(ctx.Request.Url?.Query);
93 query.TryGetValue("code", out var code);
94 query.TryGetValue("state", out var state);
95 query.TryGetValue("error", out var error);
96
97 var body = !string.IsNullOrWhiteSpace(error)
98 ? $"<html><body><p>Ошибка: {WebUtility.HtmlEncode(error)}</p></body></html>"
99 : "<html><body><p>Signed in to Forge. Close this window.</p></body></html>";
100 var bytes = Encoding.UTF8.GetBytes(body);
101 ctx.Response.ContentType = "text/html; charset=utf-8";
102 ctx.Response.ContentLength64 = bytes.Length;
103 await ctx.Response.OutputStream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
104 ctx.Response.Close();
105
106 if (!string.IsNullOrWhiteSpace(error))
107 return (null, null, error);
108
109 return (code, state, null);
110 }
111
112 return (null, null, "Отменено.");
113 }
114
115 private static Dictionary<string, string> ParseQuery(string? query)
116 {
117 var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
118 if (string.IsNullOrWhiteSpace(query))
119 return result;
120 var q = query.StartsWith('?') ? query[1..] : query;
121 foreach (var part in q.Split('&', StringSplitOptions.RemoveEmptyEntries))
122 {
123 var eq = part.IndexOf('=');
124 var key = eq < 0 ? part : part[..eq];
125 var val = eq < 0 ? "" : part[(eq + 1)..];
126 result[Uri.UnescapeDataString(key)] = Uri.UnescapeDataString(val);
127 }
128
129 return result;
130 }
131
132 private static int FindFreePort()
133 {
134 var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
135 listener.Start();
136 var port = ((IPEndPoint)listener.LocalEndpoint).Port;
137 listener.Stop();
138 return port;
139 }
140
141 private static string GenerateCodeVerifier()
142 {
143 var bytes = RandomNumberGenerator.GetBytes(32);
144 return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
145 }
146
147 private static string ComputeS256Challenge(string verifier)
148 {
149 var hash = SHA256.HashData(Encoding.ASCII.GetBytes(verifier));
150 return Convert.ToBase64String(hash).TrimEnd('=').Replace('+', '-').Replace('/', '_');
151 }
152}
153
View only · write via MCP/CIDE