Forge
csharp4405de34
1using System.Net.Http.Headers;
2using System.Text.Json;
3using System.Text.Json.Serialization;
4using IntercomService.Data;
5using IntercomService.Options;
6using Microsoft.EntityFrameworkCore;
7using Microsoft.Extensions.Options;
8
9namespace IntercomService.Services;
10
11public sealed class GitHubAuthService(
12 IntercomDbContext db,
13 IHttpClientFactory httpClientFactory,
14 IOptions<GitHubAuthOptions> githubOptions,
15 IConfiguration configuration)
16{
17 private readonly GitHubAuthOptions _github = githubOptions.Value;
18
19 public bool IsConfigured =>
20 !string.IsNullOrWhiteSpace(_github.ClientId) && !string.IsNullOrWhiteSpace(_github.ClientSecret);
21
22 public async Task<OAuthStateEntity> CreateStateAsync(
23 string teamId,
24 string redirectUri,
25 string? codeChallenge,
26 string? codeChallengeMethod,
27 string? inviteToken,
28 CancellationToken ct)
29 {
30 var state = new OAuthStateEntity
31 {
32 State = Guid.NewGuid().ToString("N"),
33 Provider = "github",
34 TeamId = teamId,
35 RedirectUri = redirectUri,
36 CodeChallenge = codeChallenge,
37 CodeChallengeMethod = codeChallengeMethod,
38 InviteToken = inviteToken,
39 ExpiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(15),
40 };
41 db.OAuthStates.Add(state);
42 await db.SaveChangesAsync(ct).ConfigureAwait(false);
43 return state;
44 }
45
46 public async Task<OAuthStateEntity?> ConsumeStateAsync(string state, CancellationToken ct)
47 {
48 var row = await db.OAuthStates
49 .FirstOrDefaultAsync(x => x.State == state && x.ExpiresAtUtc >= DateTimeOffset.UtcNow, ct)
50 .ConfigureAwait(false);
51 if (row is null)
52 return null;
53
54 db.OAuthStates.Remove(row);
55 await db.SaveChangesAsync(ct).ConfigureAwait(false);
56 return row;
57 }
58
59 public Task<OAuthStateEntity?> GetValidStateAsync(string state, CancellationToken ct) =>
60 db.OAuthStates
61 .AsNoTracking()
62 .FirstOrDefaultAsync(
63 x => x.State == state && x.ExpiresAtUtc >= DateTimeOffset.UtcNow,
64 ct);
65
66 public string BuildAuthorizeUrl(OAuthStateEntity state)
67 {
68 var callback = GetCallbackUrl();
69 var query = new Dictionary<string, string?>
70 {
71 ["client_id"] = _github.ClientId,
72 ["redirect_uri"] = callback,
73 ["scope"] = "read:user",
74 ["state"] = state.State,
75 };
76
77 if (!string.IsNullOrWhiteSpace(state.CodeChallenge))
78 {
79 query["code_challenge"] = state.CodeChallenge;
80 query["code_challenge_method"] = state.CodeChallengeMethod ?? "S256";
81 }
82
83 var qs = string.Join("&", query
84 .Where(kv => !string.IsNullOrWhiteSpace(kv.Value))
85 .Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value!)}"));
86
87 return $"https://github.com/login/oauth/authorize?{qs}";
88 }
89
90 public async Task<GitHubExchangeResult?> ExchangeCodeAsync(
91 string code,
92 OAuthStateEntity state,
93 string? codeVerifier,
94 CancellationToken ct)
95 {
96 if (!string.IsNullOrWhiteSpace(state.CodeChallenge)
97 && !OAuthPkce.ValidateS256(codeVerifier, state.CodeChallenge))
98 {
99 return null;
100 }
101
102 var client = httpClientFactory.CreateClient();
103 var callback = GetCallbackUrl();
104
105 using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, "https://github.com/login/oauth/access_token");
106 tokenRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
107 tokenRequest.Content = new FormUrlEncodedContent(buildTokenForm(code, callback, codeVerifier));
108
109 using var tokenResponse = await client.SendAsync(tokenRequest, ct).ConfigureAwait(false);
110 tokenResponse.EnsureSuccessStatusCode();
111 var tokenJson = await tokenResponse.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
112 var token = JsonSerializer.Deserialize<GitHubTokenResponse>(tokenJson, IntercomService.Contracts.IntercomJson.Web);
113 if (string.IsNullOrWhiteSpace(token?.AccessToken))
114 return null;
115
116 using var userRequest = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user");
117 userRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken);
118 userRequest.Headers.UserAgent.ParseAdd("IntercomService/1.0");
119
120 using var userResponse = await client.SendAsync(userRequest, ct).ConfigureAwait(false);
121 userResponse.EnsureSuccessStatusCode();
122 var userJson = await userResponse.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
123 var user = JsonSerializer.Deserialize<GitHubUser>(userJson, IntercomService.Contracts.IntercomJson.Web);
124 if (user is null)
125 return null;
126
127 return new GitHubExchangeResult(token.AccessToken, user);
128 }
129
130 public async Task<bool> IsMemberOfAnyOrgAsync(
131 string accessToken,
132 IReadOnlyList<string> orgs,
133 CancellationToken ct)
134 {
135 if (orgs.Count == 0)
136 return false;
137
138 var client = httpClientFactory.CreateClient();
139 foreach (var org in orgs)
140 {
141 var url = $"https://api.github.com/user/memberships/orgs/{Uri.EscapeDataString(org.Trim())}";
142 using var req = new HttpRequestMessage(HttpMethod.Get, url);
143 req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
144 req.Headers.UserAgent.ParseAdd("IntercomService/1.0");
145 using var res = await client.SendAsync(req, ct).ConfigureAwait(false);
146 if (!res.IsSuccessStatusCode)
147 continue;
148
149 var json = await res.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
150 var membership = JsonSerializer.Deserialize<GitHubOrgMembership>(json, IntercomService.Contracts.IntercomJson.Web);
151 if (membership is not null
152 && string.Equals(membership.State, "active", StringComparison.OrdinalIgnoreCase))
153 return true;
154 }
155
156 return false;
157 }
158
159 private string GetCallbackUrl()
160 {
161 var baseUrl = configuration["Intercom:PublicBaseUrl"]?.TrimEnd('/');
162 if (string.IsNullOrWhiteSpace(baseUrl))
163 baseUrl = "http://127.0.0.1:5080";
164 return $"{baseUrl}/api/v1/auth/callback/github";
165 }
166
167 private Dictionary<string, string> buildTokenForm(string code, string callback, string? codeVerifier)
168 {
169 var form = new Dictionary<string, string>
170 {
171 ["client_id"] = _github.ClientId,
172 ["client_secret"] = _github.ClientSecret,
173 ["code"] = code,
174 ["redirect_uri"] = callback,
175 };
176 if (!string.IsNullOrWhiteSpace(codeVerifier))
177 form["code_verifier"] = codeVerifier;
178 return form;
179 }
180
181 private sealed record GitHubTokenResponse(
182 [property: JsonPropertyName("access_token")] string? AccessToken);
183
184 public sealed record GitHubUser(
185 [property: JsonPropertyName("id")] long Id,
186 [property: JsonPropertyName("login")] string Login);
187
188 public sealed record GitHubExchangeResult(string AccessToken, GitHubUser User);
189
190 private sealed record GitHubOrgMembership(
191 [property: JsonPropertyName("state")] string? State);
192}
193
View only · write via MCP/CIDE