sozsoft-platform/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain/Services/McpOAuthClientManager.cs

247 lines
10 KiB
C#
Raw Normal View History

2026-09-10 14:40:43 +00:00
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using OpenIddict.Abstractions;
using Sozsoft.Platform;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.Domain.Services;
using Volo.Abp.MultiTenancy;
using Volo.Abp.OpenIddict.Applications;
namespace Sozsoft.Mcp.Services;
/// <summary>
/// Uzak MCP istemcilerinin OpenIddict uygulamalari. Iki tur istemci vardir:
/// <list type="bullet">
/// <item><b>Bilinen istemci</b> (<see cref="McpConsts.OAuthClientId"/>): geri donus adresleri
/// DbMigrator ayarinda sabit olan saglayicilar (Claude, ChatGPT). Onay ekrani gostermez.</item>
/// <item><b>Dinamik istemci</b> (<see cref="McpConsts.DynamicClientIdPrefix"/>): baska bir MCP
/// istemcisi (Cursor, VS Code, Claude Code, ...) kayit ucundan kendi uygulamasini alir. Kayit
/// herkese acik oldugu icin bu istemciler <b>acik onay</b> ister: kullanici giris yaptiktan sonra
/// hangi uygulamanin erisim istedigini gorup onaylar.</item>
/// </list>
/// Iki turde de token kullanicinin kendi yetkileriyle uretilir; istemci yetki kazanmaz.
/// </summary>
public interface IMcpOAuthClientManager
{
/// <summary>Bilinen istemciyi olusturur ya da geri donus adreslerini gunceller.</summary>
Task EnsureKnownClientAsync(IReadOnlyCollection<string> redirectUris);
/// <summary>
/// Istemci kaydi. Adresler bilinen istemcide kayitliysa onu, degilse ayni adres kumesine
/// sahip dinamik istemciyi dondurur; yoksa yenisini uretir.
/// </summary>
Task<McpClientRegistration> RegisterAsync(string? clientName, IReadOnlyCollection<string> redirectUris);
}
public class McpOAuthClientManager(
IAbpApplicationManager applicationManager,
IRepository<OpenIddictApplication, Guid> applicationRepository,
ICurrentTenant currentTenant)
: DomainService, IMcpOAuthClientManager
{
private const int MaxClientNameLength = 100;
/// <summary>Tarayici disi calistirilabilir icerik tasiyan, geri donus adresi olamayacak semalar.</summary>
private static readonly string[] ForbiddenSchemes = ["javascript", "data", "file", "vbscript", "about", "blob"];
public async Task EnsureKnownClientAsync(IReadOnlyCollection<string> redirectUris)
{
// OpenIddict uygulamalari host kapsamindadir.
using (currentTenant.Change(null))
{
var existing = await applicationManager.FindByClientIdAsync(McpConsts.OAuthClientId);
var descriptor = BuildDescriptor(
McpConsts.OAuthClientId,
"MCP Connectors",
redirectUris,
OpenIddictConstants.ConsentTypes.Implicit);
if (existing is null)
{
await applicationManager.CreateAsync(descriptor);
return;
}
// Tanim tamamen dosyadan gelir; ayar degistiyse (yeni saglayici callback'i, token omru)
// kayit yeniden yazilir. Kimlik ve istemci turu degismez.
await applicationManager.PopulateAsync(existing, descriptor);
await applicationManager.UpdateAsync(existing);
}
}
public async Task<McpClientRegistration> RegisterAsync(string? clientName, IReadOnlyCollection<string> redirectUris)
{
if (redirectUris.Count == 0)
{
return McpClientRegistration.Rejected(McpClientRegistrationError.InvalidRedirectUri, string.Empty);
}
var invalid = redirectUris.FirstOrDefault(uri => !IsAcceptableRedirectUri(uri));
if (invalid is not null)
{
return McpClientRegistration.Rejected(McpClientRegistrationError.InvalidRedirectUri, invalid);
}
using (currentTenant.Change(null))
{
var known = await applicationManager.FindByClientIdAsync(McpConsts.OAuthClientId);
if (known is not null)
{
var knownUris = await applicationManager.GetRedirectUrisAsync(known);
if (redirectUris.All(uri => knownUris.Any(registered => SameUri(registered, uri))))
{
return McpClientRegistration.Accepted(McpConsts.OAuthClientId);
}
}
var dynamicClients = await GetDynamicClientsAsync();
// Ayni istemci her baglantida yeniden kayit olabilir; ayni adres kumesi tek uygulamaya
// baglanir, tablo buyumez.
var match = dynamicClients.FirstOrDefault(client => SameUriSet(client.RedirectUris, redirectUris));
if (match.ClientId is not null)
{
return McpClientRegistration.Accepted(match.ClientId);
}
if (dynamicClients.Count >= McpConsts.MaxDynamicClients)
{
return McpClientRegistration.Rejected(McpClientRegistrationError.LimitReached, McpConsts.MaxDynamicClients.ToString(CultureInfo.InvariantCulture));
}
var clientId = McpConsts.DynamicClientIdPrefix + GuidGenerator.Create().ToString("N");
await applicationManager.CreateAsync(BuildDescriptor(
clientId,
DisplayNameOf(clientName, redirectUris.First()),
redirectUris,
OpenIddictConstants.ConsentTypes.Explicit));
return McpClientRegistration.Accepted(clientId);
}
}
private async Task<List<(string? ClientId, IReadOnlyList<string> RedirectUris)>> GetDynamicClientsAsync()
{
var query = await applicationRepository.GetQueryableAsync();
var rows = query
.Where(application => application.ClientId!.StartsWith(McpConsts.DynamicClientIdPrefix))
.Select(application => new { application.ClientId, application.RedirectUris })
.ToList();
return [.. rows.Select(row => ((string?)row.ClientId, ParseUris(row.RedirectUris)))];
}
private static AbpApplicationDescriptor BuildDescriptor(
string clientId,
string displayName,
IEnumerable<string> redirectUris,
string consentType)
{
var descriptor = new AbpApplicationDescriptor
{
ClientId = clientId,
ClientType = OpenIddictConstants.ClientTypes.Public,
ConsentType = consentType,
DisplayName = displayName
};
descriptor.Permissions.UnionWith(
[
OpenIddictConstants.Permissions.Endpoints.Authorization,
OpenIddictConstants.Permissions.Endpoints.Token,
OpenIddictConstants.Permissions.Endpoints.Revocation,
OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
OpenIddictConstants.Permissions.GrantTypes.RefreshToken,
OpenIddictConstants.Permissions.ResponseTypes.Code,
OpenIddictConstants.Permissions.Prefixes.Scope + PlatformConsts.AppName
]);
// Genel istemci gizli anahtar tasiyamaz; yetkilendirme kodunun calinmasina karsi PKCE
// zorunludur.
descriptor.Requirements.Add(OpenIddictConstants.Requirements.Features.ProofKeyForCodeExchange);
descriptor.Settings[OpenIddictConstants.Settings.TokenLifetimes.RefreshToken] =
McpConsts.RefreshTokenLifetime.ToString("c", CultureInfo.InvariantCulture);
foreach (var redirectUri in redirectUris)
{
descriptor.RedirectUris.Add(new Uri(redirectUri, UriKind.Absolute));
}
return descriptor;
}
/// <summary>
/// Kabul edilen geri donus adresleri: HTTPS, yerel makineye donen HTTP (RFC 8252 §7.3 —
/// masaustu istemciler rastgele portla dinler) ve masaustu uygulamalarin ozel semalari
/// (RFC 8252 §7.1, ornek: <c>cursor://...</c>). Yerel olmayan duz HTTP reddedilir.
/// </summary>
private static bool IsAcceptableRedirectUri(string value)
{
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || !string.IsNullOrEmpty(uri.Fragment))
{
return false;
}
if (uri.Scheme == Uri.UriSchemeHttps)
{
return true;
}
if (uri.Scheme == Uri.UriSchemeHttp)
{
return uri.IsLoopback;
}
return !ForbiddenSchemes.Contains(uri.Scheme, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Onay ekraninda gorunen ad. Istemci adi kayit isteginden gelir ve dogrulanamaz (herkes
/// kendine "Claude" diyebilir); bu yuzden token'in gidecegi adresin sahibi de gosterilir.
/// </summary>
private static string DisplayNameOf(string? clientName, string redirectUri)
{
var name = string.IsNullOrWhiteSpace(clientName) ? "MCP Client" : clientName.Trim();
if (name.Length > MaxClientNameLength)
{
name = name[..MaxClientNameLength];
}
var uri = new Uri(redirectUri, UriKind.Absolute);
var owner = uri.IsLoopback ? "localhost" : string.IsNullOrEmpty(uri.Host) ? uri.Scheme : uri.Host;
return $"{name} ({owner})";
}
private static IReadOnlyList<string> ParseUris(string? json)
=> string.IsNullOrWhiteSpace(json) ? [] : JsonSerializer.Deserialize<List<string>>(json) ?? [];
private static bool SameUriSet(IReadOnlyCollection<string> left, IReadOnlyCollection<string> right)
=> left.Count == right.Count && right.All(uri => left.Any(candidate => SameUri(candidate, uri)));
/// <summary>OpenIddict adresleri <see cref="Uri"/> bicimiyle saklar; karsilastirma ayni bicimle yapilir.</summary>
private static bool SameUri(string left, string right)
=> Uri.TryCreate(left, UriKind.Absolute, out var a)
&& Uri.TryCreate(right, UriKind.Absolute, out var b)
&& string.Equals(a.AbsoluteUri, b.AbsoluteUri, StringComparison.Ordinal);
}
public enum McpClientRegistrationError
{
InvalidRedirectUri,
LimitReached
}
/// <summary>Kayit sonucu: basariliysa istemci kimligi, degilse hata ve hataya konu deger.</summary>
public sealed record McpClientRegistration(string? ClientId, McpClientRegistrationError? Error, string? ErrorValue)
{
public static McpClientRegistration Accepted(string clientId) => new(clientId, null, null);
public static McpClientRegistration Rejected(McpClientRegistrationError error, string value) => new(null, error, value);
}