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 OidcAuthService(
12 IntercomDbContext db,
13 IHttpClientFactory httpClientFactory,
14 IOptions<OidcAuthOptions> oidcOptions,
15 IConfiguration configuration)
16{
17 private readonly OidcAuthOptions _oidc = oidcOptions.Value;
18
19 public bool IsConfigured =>
20 !string.IsNullOrWhiteSpace(_oidc.Authority)
21 && !string.IsNullOrWhiteSpace(_oidc.ClientId)
22 && !string.IsNullOrWhiteSpace(_oidc.ClientSecret);
23
24 public async Task<OAuthStateEntity> CreateStateAsync(
25 string teamId,
26 string redirectUri,
27 string? codeChallenge,
28 string? codeChallengeMethod,
29 string? inviteToken,
30 CancellationToken ct)
31 {
32 var state = new OAuthStateEntity
33 {
34 State = Guid.NewGuid().ToString("N"),
35 Provider = "oidc",
36 TeamId = teamId,
37 RedirectUri = redirectUri,
38 CodeChallenge = codeChallenge,
39 CodeChallengeMethod = codeChallengeMethod,
40 InviteToken = inviteToken,
41 ExpiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(15),
42 };
43 db.OAuthStates.Add(state);
44 await db.SaveChangesAsync(ct).ConfigureAwait(false);
45 return state;
46 }
47
48 public async Task<OAuthStateEntity?> ConsumeStateAsync(string state, CancellationToken ct)
49 {
50 var row = await db.OAuthStates
51 .FirstOrDefaultAsync(x => x.State == state && x.ExpiresAtUtc >= DateTimeOffset.UtcNow, ct)
52 .ConfigureAwait(false);
53 if (row is null)
54 return null;
55
56 db.OAuthStates.Remove(row);
57 await db.SaveChangesAsync(ct).ConfigureAwait(false);
58 return row;
59 }
60
61 public Task<OAuthStateEntity?> GetValidStateAsync(string state, CancellationToken ct) =>
62 db.OAuthStates
63 .AsNoTracking()
64 .FirstOrDefaultAsync(
65 x => x.State == state && x.ExpiresAtUtc >= DateTimeOffset.UtcNow,
66 ct);
67
68 public string BuildAuthorizeUrl(OAuthStateEntity state)
69 {
70 var meta = GetMetadata();
71 var query = new Dictionary<string, string?>
72 {
73 ["client_id"] = _oidc.ClientId,
74 ["redirect_uri"] = GetCallbackUrl(),
75 ["response_type"] = "code",
76 ["scope"] = _oidc.Scopes,
77 ["state"] = state.State,
78 };
79
80 if (!string.IsNullOrWhiteSpace(state.CodeChallenge))
81 {
82 query["code_challenge"] = state.CodeChallenge;
83 query["code_challenge_method"] = state.CodeChallengeMethod ?? "S256";
84 }
85
86 var qs = string.Join("&", query
87 .Where(kv => !string.IsNullOrWhiteSpace(kv.Value))
88 .Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value!)}"));
89
90 return $"{meta.AuthorizationEndpoint}?{qs}";
91 }
92
93 public async Task<OidcUser?> ExchangeCodeAsync(
94 string code,
95 OAuthStateEntity state,
96 string? codeVerifier,
97 CancellationToken ct)
98 {
99 if (!string.IsNullOrWhiteSpace(state.CodeChallenge)
100 && !OAuthPkce.ValidateS256(codeVerifier, state.CodeChallenge))
101 {
102 return null;
103 }
104
105 var meta = GetMetadata();
106 var client = httpClientFactory.CreateClient();
107
108 using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, meta.TokenEndpoint);
109 var form = new Dictionary<string, string>
110 {
111 ["grant_type"] = "authorization_code",
112 ["code"] = code,
113 ["redirect_uri"] = GetCallbackUrl(),
114 ["client_id"] = _oidc.ClientId,
115 ["client_secret"] = _oidc.ClientSecret,
116 };
117 if (!string.IsNullOrWhiteSpace(codeVerifier))
118 form["code_verifier"] = codeVerifier;
119 tokenRequest.Content = new FormUrlEncodedContent(form);
120
121 using var tokenResponse = await client.SendAsync(tokenRequest, ct).ConfigureAwait(false);
122 tokenResponse.EnsureSuccessStatusCode();
123 var tokenJson = await tokenResponse.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
124 var token = JsonSerializer.Deserialize<OidcTokenResponse>(tokenJson, IntercomService.Contracts.IntercomJson.Web);
125 if (string.IsNullOrWhiteSpace(token?.AccessToken))
126 return null;
127
128 using var userInfoRequest = new HttpRequestMessage(HttpMethod.Get, meta.UserInfoEndpoint);
129 userInfoRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken);
130 using var userInfoResponse = await client.SendAsync(userInfoRequest, ct).ConfigureAwait(false);
131 userInfoResponse.EnsureSuccessStatusCode();
132 var userJson = await userInfoResponse.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
133 var doc = JsonDocument.Parse(userJson);
134 var root = doc.RootElement;
135 var sub = root.TryGetProperty("sub", out var subEl) ? subEl.GetString() : null;
136 if (string.IsNullOrWhiteSpace(sub))
137 return null;
138
139 var name = root.TryGetProperty("name", out var nameEl) ? nameEl.GetString()
140 : root.TryGetProperty("preferred_username", out var pu) ? pu.GetString()
141 : sub;
142
143 return new OidcUser(_oidc.Authority.TrimEnd('/'), sub, name ?? sub);
144 }
145
146 private string GetCallbackUrl()
147 {
148 var baseUrl = configuration["Intercom:PublicBaseUrl"]?.TrimEnd('/');
149 if (string.IsNullOrWhiteSpace(baseUrl))
150 baseUrl = "http://127.0.0.1:5080";
151 return $"{baseUrl}/api/v1/auth/callback/oidc";
152 }
153
154 private OidcMetadata GetMetadata()
155 {
156 var authority = _oidc.Authority.TrimEnd('/');
157 return new OidcMetadata(
158 $"{authority}/connect/authorize",
159 $"{authority}/connect/token",
160 $"{authority}/connect/userinfo");
161 }
162
163 private sealed record OidcMetadata(string AuthorizationEndpoint, string TokenEndpoint, string UserInfoEndpoint);
164
165 private sealed record OidcTokenResponse(
166 [property: JsonPropertyName("access_token")] string? AccessToken);
167
168 public sealed record OidcUser(string Issuer, string Subject, string DisplayName);
169}
170
View only · write via MCP/CIDE