Forge
csharpdeeb25a2
1using System.Diagnostics;
2using System.Net;
3using System.Security.Cryptography;
4using System.Text;
5using CascadeIDE.Models;
6using CascadeIDE.Services;
7
8namespace CascadeIDE.Features.Intercom.Transport;
9
10/// <summary>Connect Intercom: system browser + loopback callback (ADR 0144 §8).</summary>
11public sealed class IntercomOAuthConnectService(IntercomTransportApiClient api)
12{
13 public Task<(bool Ok, string Error)> ConnectAsync(
14 string baseUrl,
15 string teamId,
16 string provider,
17 string? inviteToken,
18 CancellationToken ct) =>
19 ConnectCoreAsync(baseUrl, teamId, provider, inviteToken, ct);
20
21 public async Task<(bool Ok, string Error)> ConnectGitHubAsync(
22 string baseUrl,
23 string teamId,
24 CancellationToken ct) =>
25 await ConnectCoreAsync(baseUrl, teamId, "github", null, ct).ConfigureAwait(false);
26
27 private async Task<(bool Ok, string Error)> ConnectCoreAsync(
28 string baseUrl,
29 string teamId,
30 string provider,
31 string? inviteToken,
32 CancellationToken ct)
33 {
34 api.ConfigureBaseUrl(baseUrl);
35 var normalizedProvider = string.IsNullOrWhiteSpace(provider) ? "github" : provider.Trim().ToLowerInvariant();
36
37 var verifier = GenerateCodeVerifier();
38 var challenge = ComputeS256Challenge(verifier);
39 var port = FindFreePort();
40 var redirectUri = $"http://127.0.0.1:{port}/callback";
41
42 using var listener = new HttpListener();
43 listener.Prefixes.Add($"http://127.0.0.1:{port}/");
44 listener.Start();
45
46 var loginUrl =
47 $"{baseUrl.TrimEnd('/')}/api/v1/auth/login" +
48 $"?provider={Uri.EscapeDataString(normalizedProvider)}&team_id={Uri.EscapeDataString(teamId)}" +
49 $"&redirect_uri={Uri.EscapeDataString(redirectUri)}" +
50 $"&code_challenge={Uri.EscapeDataString(challenge)}&code_challenge_method=S256"
51 + (string.IsNullOrWhiteSpace(inviteToken)
52 ? ""
53 : $"&invite_token={Uri.EscapeDataString(inviteToken.Trim())}");
54
55 try
56 {
57 Process.Start(new ProcessStartInfo(loginUrl) { UseShellExecute = true });
58 }
59 catch (Exception ex)
60 {
61 listener.Stop();
62 return (false, "Не удалось открыть браузер: " + ex.Message);
63 }
64
65 var waitTask = WaitForCallbackAsync(listener, api, redirectUri, verifier, ct);
66 var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromMinutes(5), ct)).ConfigureAwait(false);
67 listener.Stop();
68
69 if (completed != waitTask)
70 return (false, "Таймаут OAuth (5 мин).");
71
72 var (ok, access, refresh, expiresIn, error) = await waitTask.ConfigureAwait(false);
73 if (!ok)
74 return (false, error);
75
76 var secrets = IntercomTransportSecretsStorage.Load();
77 secrets.AccessToken = access;
78 secrets.RefreshToken = refresh;
79 secrets.SetAccessExpiry(DateTimeOffset.UtcNow.AddSeconds(Math.Max(30, expiresIn - 30)));
80 IntercomTransportSecretsStorage.Save(secrets);
81
82 var me = await api.GetMeAsync(access, ct).ConfigureAwait(false);
83 if (me is not null)
84 {
85 secrets.MemberId = me.MemberId;
86 secrets.DisplayName = me.DisplayName;
87 IntercomTransportSecretsStorage.Save(secrets);
88 }
89
90 return (true, "");
91 }
92
93 private async Task<(bool Ok, string Access, string Refresh, int ExpiresIn, string Error)> WaitForCallbackAsync(
94 HttpListener listener,
95 IntercomTransportApiClient api,
96 string redirectUri,
97 string codeVerifier,
98 CancellationToken ct)
99 {
100 while (!ct.IsCancellationRequested)
101 {
102 var ctx = await listener.GetContextAsync().ConfigureAwait(false);
103 var query = ParseQuery(ctx.Request.Url?.Query);
104 query.TryGetValue("code", out var code);
105 query.TryGetValue("state", out var state);
106 query.TryGetValue("error", out var error);
107
108 var body = !string.IsNullOrWhiteSpace(error)
109 ? $"<html><body><p>Ошибка: {WebUtility.HtmlEncode(error)}</p><p>Можно закрыть окно.</p></body></html>"
110 : "<html><body><p>Intercom подключён. Можно закрыть окно и вернуться в CIDE.</p></body></html>";
111 var bytes = Encoding.UTF8.GetBytes(body);
112 ctx.Response.ContentType = "text/html; charset=utf-8";
113 ctx.Response.ContentLength64 = bytes.Length;
114 await ctx.Response.OutputStream.WriteAsync(bytes, ct).ConfigureAwait(false);
115 ctx.Response.Close();
116
117 if (!string.IsNullOrWhiteSpace(error))
118 return (false, "", "", 0, error);
119
120 if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(state))
121 return (false, "", "", 0, "В callback нет code/state.");
122
123 var tokens = await api.ExchangeAuthorizationCodeAsync(code, state, codeVerifier, redirectUri, ct)
124 .ConfigureAwait(false);
125 if (tokens is null)
126 return (false, "", "", 0, "Обмен code на токен не удался.");
127
128 return (true, tokens.AccessToken, tokens.RefreshToken, tokens.ExpiresIn, "");
129 }
130
131 return (false, "", "", 0, "Отменено.");
132 }
133
134 private static Dictionary<string, string> ParseQuery(string? query)
135 {
136 var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
137 if (string.IsNullOrWhiteSpace(query))
138 return result;
139 var q = query.StartsWith('?') ? query[1..] : query;
140 foreach (var part in q.Split('&', StringSplitOptions.RemoveEmptyEntries))
141 {
142 var eq = part.IndexOf('=');
143 var key = eq < 0 ? part : part[..eq];
144 var val = eq < 0 ? "" : part[(eq + 1)..];
145 result[Uri.UnescapeDataString(key)] = Uri.UnescapeDataString(val);
146 }
147
148 return result;
149 }
150
151 private static int FindFreePort()
152 {
153 var l = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
154 l.Start();
155 var port = ((IPEndPoint)l.LocalEndpoint).Port;
156 l.Stop();
157 return port;
158 }
159
160 private static string GenerateCodeVerifier()
161 {
162 var bytes = new byte[32];
163 RandomNumberGenerator.Fill(bytes);
164 return Base64UrlEncode(bytes);
165 }
166
167 private static string ComputeS256Challenge(string verifier)
168 {
169 var hash = SHA256.HashData(Encoding.ASCII.GetBytes(verifier));
170 return Base64UrlEncode(hash);
171 }
172
173 private static string Base64UrlEncode(byte[] data) =>
174 Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
175}
176
View only · write via MCP/CIDE