| 1 | using System.Net.Http.Headers; |
| 2 | using System.Text.Json; |
| 3 | using System.Text.Json.Serialization; |
| 4 | using IntercomService.Data; |
| 5 | using IntercomService.Options; |
| 6 | using Microsoft.EntityFrameworkCore; |
| 7 | using Microsoft.Extensions.Options; |
| 8 | |
| 9 | namespace IntercomService.Services; |
| 10 | |
| 11 | public 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 | |