246 lines
10 KiB
C#
246 lines
10 KiB
C#
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);
|
|
}
|