diff --git a/MCPify/Core/Auth/DeviceCode/DeviceCodeAuthentication.cs b/MCPify/Core/Auth/DeviceCode/DeviceCodeAuthentication.cs index f3c7626..a74d6c7 100644 --- a/MCPify/Core/Auth/DeviceCode/DeviceCodeAuthentication.cs +++ b/MCPify/Core/Auth/DeviceCode/DeviceCodeAuthentication.cs @@ -15,6 +15,7 @@ public class DeviceCodeAuthentication : IAuthenticationProvider private readonly IMcpContextAccessor _mcpContextAccessor; private readonly HttpClient _httpClient; private readonly Func _userPrompt; + private readonly string? _resourceUrl; // RFC 8707 resource parameter private const string _deviceCodeProviderName = "DeviceCode"; public DeviceCodeAuthentication( @@ -25,7 +26,8 @@ public DeviceCodeAuthentication( ISecureTokenStore secureTokenStore, IMcpContextAccessor mcpContextAccessor, Func userPrompt, - HttpClient? httpClient = null) + HttpClient? httpClient = null, + string? resourceUrl = null) { _clientId = clientId; _deviceCodeEndpoint = deviceCodeEndpoint; @@ -35,6 +37,7 @@ public DeviceCodeAuthentication( _mcpContextAccessor = mcpContextAccessor; _userPrompt = userPrompt; _httpClient = httpClient ?? new HttpClient(); + _resourceUrl = resourceUrl; } public async Task ApplyAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) @@ -73,11 +76,11 @@ public async Task ApplyAsync(HttpRequestMessage request, CancellationToken cance private async Task PerformDeviceLoginAsync(CancellationToken cancellationToken) { - var codeRequest = new FormUrlEncodedContent(new Dictionary - { - { "client_id", _clientId }, - { "scope", _scope } - }); + var codeRequest = FormUrlEncoded.Create() + .Add("client_id", _clientId) + .Add("scope", _scope) + .AddIfNotEmpty("resource", _resourceUrl) // RFC 8707 + .ToContent(); var codeResponse = await _httpClient.PostAsync(_deviceCodeEndpoint, codeRequest, cancellationToken); codeResponse.EnsureSuccessStatusCode(); @@ -94,12 +97,12 @@ private async Task PerformDeviceLoginAsync(CancellationToken cancella { await Task.Delay(interval * 1000, cancellationToken); - var tokenRequest = new FormUrlEncodedContent(new Dictionary - { - { "grant_type", "urn:ietf:params:oauth:grant-type:device_code" }, - { "client_id", _clientId }, - { "device_code", codeData.device_code } - }); + var tokenRequest = FormUrlEncoded.Create() + .Add("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + .Add("client_id", _clientId) + .Add("device_code", codeData.device_code) + .AddIfNotEmpty("resource", _resourceUrl) // RFC 8707 + .ToContent(); var tokenResponse = await _httpClient.PostAsync(_tokenEndpoint, tokenRequest, cancellationToken); @@ -125,12 +128,12 @@ private async Task PerformDeviceLoginAsync(CancellationToken cancella private async Task RefreshTokenAsync(string refreshToken, CancellationToken cancellationToken) { - var content = new FormUrlEncodedContent(new Dictionary - { - { "grant_type", "refresh_token" }, - { "client_id", _clientId }, - { "refresh_token", refreshToken } - }); + var content = FormUrlEncoded.Create() + .Add("grant_type", "refresh_token") + .Add("client_id", _clientId) + .Add("refresh_token", refreshToken) + .AddIfNotEmpty("resource", _resourceUrl) // RFC 8707 + .ToContent(); var response = await _httpClient.PostAsync(_tokenEndpoint, content, cancellationToken); response.EnsureSuccessStatusCode(); diff --git a/MCPify/Core/Auth/FormUrlEncoded.cs b/MCPify/Core/Auth/FormUrlEncoded.cs new file mode 100644 index 0000000..60bd6d7 --- /dev/null +++ b/MCPify/Core/Auth/FormUrlEncoded.cs @@ -0,0 +1,28 @@ +namespace MCPify.Core.Auth; + +/// +/// Fluent helper for creating application/x-www-form-urlencoded POST content. +/// +internal class FormUrlEncoded +{ + private readonly List> _params = new(); + + public static FormUrlEncoded Create() => new(); + + public FormUrlEncoded Add(string key, string value) + { + _params.Add(new(key, value)); + return this; + } + + public FormUrlEncoded AddIfNotEmpty(string key, string? value) + { + if (!string.IsNullOrEmpty(value)) + { + _params.Add(new(key, value)); + } + return this; + } + + public FormUrlEncodedContent ToContent() => new(_params); +} diff --git a/MCPify/Core/Auth/IAccessTokenValidator.cs b/MCPify/Core/Auth/IAccessTokenValidator.cs new file mode 100644 index 0000000..519e47c --- /dev/null +++ b/MCPify/Core/Auth/IAccessTokenValidator.cs @@ -0,0 +1,16 @@ +namespace MCPify.Core.Auth; + +/// +/// Interface for validating access tokens. +/// +public interface IAccessTokenValidator +{ + /// + /// Validates an access token and returns the validation result. + /// + /// The access token to validate. + /// Optional expected audience value. If null, audience validation is skipped. + /// Cancellation token. + /// A containing the validation outcome and extracted claims. + Task ValidateAsync(string token, string? expectedAudience, CancellationToken cancellationToken = default); +} diff --git a/MCPify/Core/Auth/JwtAccessTokenValidator.cs b/MCPify/Core/Auth/JwtAccessTokenValidator.cs new file mode 100644 index 0000000..c6f8dec --- /dev/null +++ b/MCPify/Core/Auth/JwtAccessTokenValidator.cs @@ -0,0 +1,203 @@ +using System.Text; +using System.Text.Json; + +namespace MCPify.Core.Auth; + +/// +/// JWT access token validator that parses and validates JWT tokens without signature verification. +/// This is suitable for tokens that have already been cryptographically validated by the authorization server. +/// Performs expiration, audience, and scope claim extraction. +/// +public class JwtAccessTokenValidator : IAccessTokenValidator +{ + private readonly TokenValidationOptions _options; + private static readonly string[] ScopeClaimNames = { "scope", "scp", "scopes" }; + + public JwtAccessTokenValidator(TokenValidationOptions options) + { + _options = options; + } + + public Task ValidateAsync(string token, string? expectedAudience, CancellationToken cancellationToken = default) + { + try + { + var parts = token.Split('.'); + if (parts.Length < 2) + { + return Task.FromResult(TokenValidationResult.Failure("invalid_token", "Token is not a valid JWT format")); + } + + var payloadJson = Base64UrlDecode(parts[1]); + using var doc = JsonDocument.Parse(payloadJson); + var root = doc.RootElement; + + // Extract claims + var subject = GetStringClaim(root, "sub"); + var issuer = GetStringClaim(root, "iss"); + var audiences = GetAudienceClaim(root); + var scopes = GetScopeClaim(root); + var expiresAt = GetExpirationClaim(root); + + // Validate expiration + if (expiresAt.HasValue) + { + var now = DateTimeOffset.UtcNow; + if (expiresAt.Value.Add(_options.ClockSkew) < now) + { + return Task.FromResult(TokenValidationResult.Failure("invalid_token", "Token has expired")); + } + } + + // Validate audience if requested + if (_options.ValidateAudience && !string.IsNullOrEmpty(expectedAudience)) + { + if (audiences.Count == 0 || !audiences.Any(a => string.Equals(a, expectedAudience, StringComparison.OrdinalIgnoreCase))) + { + return Task.FromResult(TokenValidationResult.Failure("invalid_token", $"Token audience does not match expected value: {expectedAudience}")); + } + } + + return Task.FromResult(TokenValidationResult.Success( + scopes: scopes, + subject: subject, + audiences: audiences, + issuer: issuer, + expiresAt: expiresAt + )); + } + catch (JsonException) + { + return Task.FromResult(TokenValidationResult.Failure("invalid_token", "Token payload is not valid JSON")); + } + catch (FormatException) + { + return Task.FromResult(TokenValidationResult.Failure("invalid_token", "Token payload is not valid Base64URL")); + } + catch (Exception ex) + { + return Task.FromResult(TokenValidationResult.Failure("invalid_token", $"Token validation failed: {ex.Message}")); + } + } + + private static string? GetStringClaim(JsonElement root, string claimName) + { + if (root.TryGetProperty(claimName, out var claim) && claim.ValueKind == JsonValueKind.String) + { + return claim.GetString(); + } + return null; + } + + private List GetAudienceClaim(JsonElement root) + { + if (!root.TryGetProperty("aud", out var audClaim)) + { + return new List(); + } + + if (audClaim.ValueKind == JsonValueKind.String) + { + var value = audClaim.GetString(); + return value != null ? new List { value } : new List(); + } + + if (audClaim.ValueKind == JsonValueKind.Array) + { + var audiences = new List(); + foreach (var item in audClaim.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + var value = item.GetString(); + if (value != null) + { + audiences.Add(value); + } + } + } + return audiences; + } + + return new List(); + } + + private List GetScopeClaim(JsonElement root) + { + // Try the configured claim name first, then fall back to common alternatives + var claimNamesToTry = new List { _options.ScopeClaimName }; + foreach (var name in ScopeClaimNames) + { + if (!claimNamesToTry.Contains(name, StringComparer.OrdinalIgnoreCase)) + { + claimNamesToTry.Add(name); + } + } + + foreach (var claimName in claimNamesToTry) + { + if (!root.TryGetProperty(claimName, out var scopeClaim)) + { + continue; + } + + if (scopeClaim.ValueKind == JsonValueKind.String) + { + var value = scopeClaim.GetString(); + if (!string.IsNullOrEmpty(value)) + { + // Scopes are space-separated per RFC 6749 + return value.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList(); + } + } + + if (scopeClaim.ValueKind == JsonValueKind.Array) + { + var scopes = new List(); + foreach (var item in scopeClaim.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + var value = item.GetString(); + if (!string.IsNullOrEmpty(value)) + { + scopes.Add(value); + } + } + } + return scopes; + } + } + + return new List(); + } + + private static DateTimeOffset? GetExpirationClaim(JsonElement root) + { + if (!root.TryGetProperty("exp", out var expClaim)) + { + return null; + } + + if (expClaim.ValueKind == JsonValueKind.Number) + { + var unixTime = expClaim.GetInt64(); + return DateTimeOffset.FromUnixTimeSeconds(unixTime); + } + + return null; + } + + private static byte[] Base64UrlDecode(string input) + { + var output = input.Replace('-', '+').Replace('_', '/'); + switch (output.Length % 4) + { + case 0: break; + case 2: output += "=="; break; + case 3: output += "="; break; + default: throw new FormatException("Illegal base64url string!"); + } + return Convert.FromBase64String(output); + } +} diff --git a/MCPify/Core/Auth/OAuth/ClientCredentialsAuthentication.cs b/MCPify/Core/Auth/OAuth/ClientCredentialsAuthentication.cs index 6ec3d72..5aa48f9 100644 --- a/MCPify/Core/Auth/OAuth/ClientCredentialsAuthentication.cs +++ b/MCPify/Core/Auth/OAuth/ClientCredentialsAuthentication.cs @@ -14,6 +14,7 @@ public class ClientCredentialsAuthentication : IAuthenticationProvider private readonly ISecureTokenStore _secureTokenStore; private readonly IMcpContextAccessor _mcpContextAccessor; private readonly HttpClient _httpClient; + private readonly string? _resourceUrl; // RFC 8707 resource parameter private const string _clientCredentialsProviderName = "ClientCredentials"; public ClientCredentialsAuthentication( @@ -23,7 +24,8 @@ public ClientCredentialsAuthentication( string scope, ISecureTokenStore secureTokenStore, IMcpContextAccessor mcpContextAccessor, - HttpClient? httpClient = null) + HttpClient? httpClient = null, + string? resourceUrl = null) { _clientId = clientId; _clientSecret = clientSecret; @@ -32,6 +34,7 @@ public ClientCredentialsAuthentication( _secureTokenStore = secureTokenStore; _mcpContextAccessor = mcpContextAccessor; _httpClient = httpClient ?? new HttpClient(); + _resourceUrl = resourceUrl; } public async Task ApplyAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) @@ -54,15 +57,13 @@ public async Task ApplyAsync(HttpRequestMessage request, CancellationToken cance private async Task RequestTokenAsync(CancellationToken cancellationToken) { - var form = new Dictionary - { - { "grant_type", "client_credentials" }, - { "client_id", _clientId }, - { "client_secret", _clientSecret }, - { "scope", _scope } - }; - - var content = new FormUrlEncodedContent(form); + var content = FormUrlEncoded.Create() + .Add("grant_type", "client_credentials") + .Add("client_id", _clientId) + .Add("client_secret", _clientSecret) + .Add("scope", _scope) + .AddIfNotEmpty("resource", _resourceUrl) // RFC 8707 + .ToContent(); var response = await _httpClient.PostAsync(_tokenEndpoint, content, cancellationToken); response.EnsureSuccessStatusCode(); diff --git a/MCPify/Core/Auth/OAuth/OAuthAuthorizationCodeAuthentication.cs b/MCPify/Core/Auth/OAuth/OAuthAuthorizationCodeAuthentication.cs index 6f2376b..ec5e690 100644 --- a/MCPify/Core/Auth/OAuth/OAuthAuthorizationCodeAuthentication.cs +++ b/MCPify/Core/Auth/OAuth/OAuthAuthorizationCodeAuthentication.cs @@ -31,6 +31,7 @@ public class OAuthAuthorizationCodeAuthentication : IAuthenticationProvider private readonly string _stateSecret; private readonly bool _allowDefaultSessionFallback; private readonly ISessionMap? _sessionMap; // Optional dependency for Lazy Auth + private readonly string? _resourceUrl; // RFC 8707 resource parameter private const string _oauthProviderName = "OAuth"; private const string _pkceStorePrefix = "pkce_"; @@ -49,7 +50,8 @@ public OAuthAuthorizationCodeAuthentication( Action? authorizationUrlEmitter = null, string? stateSecret = null, bool allowDefaultSessionFallback = false, - ISessionMap? sessionMap = null) + ISessionMap? sessionMap = null, + string? resourceUrl = null) { _clientId = clientId; _authorizationEndpoint = authorizationEndpoint; @@ -66,6 +68,7 @@ public OAuthAuthorizationCodeAuthentication( _stateSecret = stateSecret ?? "A_VERY_LONG_AND_SECURE_SECRET_KEY_FOR_HMAC_SIGNING"; _allowDefaultSessionFallback = allowDefaultSessionFallback; _sessionMap = sessionMap; + _resourceUrl = resourceUrl; } public virtual string BuildAuthorizationUrl(string sessionId) @@ -92,6 +95,11 @@ public virtual string BuildAuthorizationUrl(string sessionId) query["code_challenge"] = pkce.Value.CodeChallenge; query["code_challenge_method"] = "S256"; } + // RFC 8707: Add resource parameter if configured + if (!string.IsNullOrEmpty(_resourceUrl)) + { + query["resource"] = _resourceUrl; + } return $"{_authorizationEndpoint}?{query}"; } @@ -221,25 +229,15 @@ public virtual async Task HandleAuthorizationCallbackAsync(string cod private async Task ExchangeCodeForTokenAsync(string code, string redirectUri, string? codeVerifier, CancellationToken cancellationToken) { - var form = new Dictionary - { - { "grant_type", "authorization_code" }, - { "client_id", _clientId }, - { "code", code }, - { "redirect_uri", redirectUri } - }; - - if (!string.IsNullOrEmpty(codeVerifier)) - { - form["code_verifier"] = codeVerifier; - } - - if (!string.IsNullOrEmpty(_clientSecret)) - { - form["client_secret"] = _clientSecret; - } - - var content = new FormUrlEncodedContent(form); + var content = FormUrlEncoded.Create() + .Add("grant_type", "authorization_code") + .Add("client_id", _clientId) + .Add("code", code) + .Add("redirect_uri", redirectUri) + .AddIfNotEmpty("code_verifier", codeVerifier) + .AddIfNotEmpty("client_secret", _clientSecret) + .AddIfNotEmpty("resource", _resourceUrl) // RFC 8707 + .ToContent(); var response = await _httpClient.PostAsync(_tokenEndpoint, content, cancellationToken); response.EnsureSuccessStatusCode(); @@ -263,19 +261,13 @@ private async Task ExchangeCodeForTokenAsync(string code, string redi private async Task RefreshTokenAsync(string refreshToken, string sessionId, CancellationToken cancellationToken) { - var form = new Dictionary - { - { "grant_type", "refresh_token" }, - { "client_id", _clientId }, - { "refresh_token", refreshToken } - }; - - if (!string.IsNullOrEmpty(_clientSecret)) - { - form["client_secret"] = _clientSecret; - } - - var content = new FormUrlEncodedContent(form); + var content = FormUrlEncoded.Create() + .Add("grant_type", "refresh_token") + .Add("client_id", _clientId) + .Add("refresh_token", refreshToken) + .AddIfNotEmpty("client_secret", _clientSecret) + .AddIfNotEmpty("resource", _resourceUrl) // RFC 8707 + .ToContent(); var response = await _httpClient.PostAsync(_tokenEndpoint, content, cancellationToken); response.EnsureSuccessStatusCode(); diff --git a/MCPify/Core/Auth/ScopeRequirement.cs b/MCPify/Core/Auth/ScopeRequirement.cs new file mode 100644 index 0000000..f6dc697 --- /dev/null +++ b/MCPify/Core/Auth/ScopeRequirement.cs @@ -0,0 +1,99 @@ +using System.Text.RegularExpressions; + +namespace MCPify.Core.Auth; + +/// +/// Defines scope requirements for a tool or endpoint. +/// +public class ScopeRequirement +{ + /// + /// Tool name pattern to match. Supports wildcards: + /// - '*' matches any sequence of characters + /// - '?' matches any single character + /// Examples: "admin_*", "api_get_*", "tool_name" + /// + public required string Pattern { get; init; } + + /// + /// Scopes that must ALL be present in the token. + /// If empty, only is checked. + /// + public List RequiredScopes { get; init; } = new(); + + /// + /// At least ONE of these scopes must be present in the token. + /// If empty, only is checked. + /// + public List AnyOfScopes { get; init; } = new(); + + private Regex? _compiledPattern; + + /// + /// Checks if this requirement applies to the given tool name. + /// + public bool Matches(string toolName) + { + _compiledPattern ??= CompilePattern(Pattern); + return _compiledPattern.IsMatch(toolName); + } + + /// + /// Validates that the provided scopes satisfy this requirement. + /// + /// Scopes from the access token. + /// True if the scopes satisfy the requirement, false otherwise. + public bool IsSatisfiedBy(IEnumerable tokenScopes) + { + var scopeSet = new HashSet(tokenScopes, StringComparer.OrdinalIgnoreCase); + + // Check RequiredScopes - all must be present + if (RequiredScopes.Count > 0) + { + foreach (var required in RequiredScopes) + { + if (!scopeSet.Contains(required)) + { + return false; + } + } + } + + // Check AnyOfScopes - at least one must be present + if (AnyOfScopes.Count > 0) + { + var hasAny = AnyOfScopes.Any(s => scopeSet.Contains(s)); + if (!hasAny) + { + return false; + } + } + + return true; + } + + /// + /// Gets all scopes that are required by this requirement (for error messages). + /// + public IEnumerable GetAllRequiredScopes() + { + foreach (var scope in RequiredScopes) + { + yield return scope; + } + foreach (var scope in AnyOfScopes) + { + yield return scope; + } + } + + private static Regex CompilePattern(string pattern) + { + // Escape regex special characters except * and ? + var escaped = Regex.Escape(pattern) + .Replace("\\*", ".*") + .Replace("\\?", "."); + + return new Regex($"^{escaped}$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + } +} diff --git a/MCPify/Core/Auth/ScopeRequirementStore.cs b/MCPify/Core/Auth/ScopeRequirementStore.cs new file mode 100644 index 0000000..13e39a5 --- /dev/null +++ b/MCPify/Core/Auth/ScopeRequirementStore.cs @@ -0,0 +1,145 @@ +namespace MCPify.Core.Auth; + +/// +/// Registry for scope requirements with pattern matching. +/// Determines required scopes for tools based on configured patterns. +/// +public class ScopeRequirementStore +{ + private readonly List _requirements; + private readonly TokenValidationOptions _options; + private readonly OAuthConfigurationStore? _oauthStore; + + public ScopeRequirementStore(IEnumerable requirements, TokenValidationOptions options, OAuthConfigurationStore? oauthStore = null) + { + _requirements = requirements.ToList(); + _options = options; + _oauthStore = oauthStore; + } + + /// + /// Gets all scope requirements that apply to the given tool name. + /// + /// The name of the tool. + /// All matching scope requirements. + public IEnumerable GetRequirementsForTool(string toolName) + { + return _requirements.Where(r => r.Matches(toolName)); + } + + /// + /// Validates that the provided scopes satisfy all requirements for the given tool. + /// + /// The name of the tool. + /// Scopes from the access token. + /// A validation result with missing scopes if validation fails. + public ScopeValidationResult ValidateScopesForTool(string toolName, IEnumerable tokenScopes) + { + var scopeList = tokenScopes.ToList(); + var scopeSet = new HashSet(scopeList, StringComparer.OrdinalIgnoreCase); + var missingScopes = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Check default required scopes first + foreach (var defaultScope in _options.DefaultRequiredScopes) + { + if (!scopeSet.Contains(defaultScope)) + { + missingScopes.Add(defaultScope); + } + } + + // Check OAuth-configured scopes if enabled + if (_options.RequireOAuthConfiguredScopes && _oauthStore != null) + { + var oauthScopes = _oauthStore.GetConfigurations() + .SelectMany(c => c.Scopes.Keys) + .Distinct(StringComparer.OrdinalIgnoreCase); + + foreach (var oauthScope in oauthScopes) + { + if (!scopeSet.Contains(oauthScope)) + { + missingScopes.Add(oauthScope); + } + } + } + + // Check tool-specific requirements + var matchingRequirements = GetRequirementsForTool(toolName).ToList(); + foreach (var requirement in matchingRequirements) + { + if (!requirement.IsSatisfiedBy(scopeList)) + { + // Add all scopes from this requirement to missing list + foreach (var scope in requirement.GetAllRequiredScopes()) + { + if (!scopeSet.Contains(scope)) + { + missingScopes.Add(scope); + } + } + } + } + + if (missingScopes.Count > 0) + { + return ScopeValidationResult.Failure(missingScopes.ToList()); + } + + return ScopeValidationResult.Success(); + } + + /// + /// Gets all required scopes for a tool (for WWW-Authenticate header). + /// + public IEnumerable GetRequiredScopesForTool(string toolName) + { + var scopes = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Add default scopes + foreach (var scope in _options.DefaultRequiredScopes) + { + scopes.Add(scope); + } + + // Add OAuth-configured scopes if enabled + if (_options.RequireOAuthConfiguredScopes && _oauthStore != null) + { + foreach (var config in _oauthStore.GetConfigurations()) + { + foreach (var scope in config.Scopes.Keys) + { + scopes.Add(scope); + } + } + } + + // Add tool-specific scopes + foreach (var requirement in GetRequirementsForTool(toolName)) + { + foreach (var scope in requirement.GetAllRequiredScopes()) + { + scopes.Add(scope); + } + } + + return scopes; + } +} + +/// +/// Result of scope validation. +/// +public class ScopeValidationResult +{ + public bool IsValid { get; init; } + public IReadOnlyList MissingScopes { get; init; } = Array.Empty(); + + public static ScopeValidationResult Success() => new() { IsValid = true }; + + public static ScopeValidationResult Failure(IReadOnlyList missingScopes) => new() + { + IsValid = false, + MissingScopes = missingScopes + }; +} diff --git a/MCPify/Core/Auth/TokenValidationOptions.cs b/MCPify/Core/Auth/TokenValidationOptions.cs new file mode 100644 index 0000000..0af80a7 --- /dev/null +++ b/MCPify/Core/Auth/TokenValidationOptions.cs @@ -0,0 +1,58 @@ +namespace MCPify.Core.Auth; + +/// +/// Configuration options for token validation behavior. +/// Token validation is opt-in for backward compatibility. +/// +public class TokenValidationOptions +{ + /// + /// When true, enables JWT token validation including expiration, audience, and scope checks. + /// Defaults to false for backward compatibility. + /// + public bool EnableJwtValidation { get; set; } = false; + + /// + /// When true, validates that the token's 'aud' claim matches the expected audience (resource URL). + /// Only applies when is true. + /// + public bool ValidateAudience { get; set; } = false; + + /// + /// When true, validates that the token contains required scopes for the requested operation. + /// Only applies when is true. + /// + public bool ValidateScopes { get; set; } = false; + + /// + /// The expected audience value for token validation. + /// If not set, defaults to the resource URL. + /// + public string? ExpectedAudience { get; set; } + + /// + /// Default scopes required for all endpoints when scope validation is enabled. + /// Specific endpoints can override this with . + /// + public List DefaultRequiredScopes { get; set; } = new(); + + /// + /// When true, automatically requires all scopes defined in + /// from the in addition to . + /// Defaults to false for backward compatibility. + /// + public bool RequireOAuthConfiguredScopes { get; set; } = false; + + /// + /// The claim name used for scopes in the JWT token. + /// Common values: "scope", "scp", "scopes". + /// Defaults to "scope". + /// + public string ScopeClaimName { get; set; } = "scope"; + + /// + /// Allowed clock skew for token expiration validation. + /// Defaults to 5 minutes. + /// + public TimeSpan ClockSkew { get; set; } = TimeSpan.FromMinutes(5); +} diff --git a/MCPify/Core/Auth/TokenValidationResult.cs b/MCPify/Core/Auth/TokenValidationResult.cs new file mode 100644 index 0000000..bcf4a20 --- /dev/null +++ b/MCPify/Core/Auth/TokenValidationResult.cs @@ -0,0 +1,81 @@ +namespace MCPify.Core.Auth; + +/// +/// Result of access token validation. +/// +public class TokenValidationResult +{ + /// + /// Whether the token is valid. + /// + public bool IsValid { get; init; } + + /// + /// Error code when validation fails (e.g., "invalid_token", "expired_token"). + /// + public string? ErrorCode { get; init; } + + /// + /// Human-readable description of the error. + /// + public string? ErrorDescription { get; init; } + + /// + /// Scopes extracted from the token. + /// + public IReadOnlyList Scopes { get; init; } = Array.Empty(); + + /// + /// The subject (sub) claim from the token. + /// + public string? Subject { get; init; } + + /// + /// The audiences (aud) claim from the token. + /// + public IReadOnlyList Audiences { get; init; } = Array.Empty(); + + /// + /// The issuer (iss) claim from the token. + /// + public string? Issuer { get; init; } + + /// + /// Token expiration time if present. + /// + public DateTimeOffset? ExpiresAt { get; init; } + + /// + /// Creates a successful validation result. + /// + public static TokenValidationResult Success( + IReadOnlyList? scopes = null, + string? subject = null, + IReadOnlyList? audiences = null, + string? issuer = null, + DateTimeOffset? expiresAt = null) + { + return new TokenValidationResult + { + IsValid = true, + Scopes = scopes ?? Array.Empty(), + Subject = subject, + Audiences = audiences ?? Array.Empty(), + Issuer = issuer, + ExpiresAt = expiresAt + }; + } + + /// + /// Creates a failed validation result. + /// + public static TokenValidationResult Failure(string errorCode, string errorDescription) + { + return new TokenValidationResult + { + IsValid = false, + ErrorCode = errorCode, + ErrorDescription = errorDescription + }; + } +} diff --git a/MCPify/Core/McpifyOptions.cs b/MCPify/Core/McpifyOptions.cs index 0b728c0..ecd9867 100644 --- a/MCPify/Core/McpifyOptions.cs +++ b/MCPify/Core/McpifyOptions.cs @@ -69,6 +69,18 @@ public class McpifyOptions /// Optional list of OAuth2 configurations to be added to the OAuthConfigurationStore. /// public List OAuthConfigurations { get; set; } = new(); + + /// + /// Configuration for JWT token validation behavior. + /// When null or EnableJwtValidation is false, token validation is skipped for backward compatibility. + /// + public TokenValidationOptions? TokenValidation { get; set; } + + /// + /// Per-tool scope requirements. Patterns support wildcards (* and ?). + /// These are checked when is true. + /// + public List ScopeRequirements { get; set; } = new(); } /// diff --git a/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs b/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs index 35ed390..b1d59a0 100644 --- a/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs +++ b/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs @@ -10,6 +10,11 @@ public class McpOAuthAuthenticationMiddleware { private readonly RequestDelegate _next; + /// + /// Key for storing token validation result in HttpContext.Items for downstream use. + /// + public const string TokenValidationResultKey = "McpTokenValidationResult"; + public McpOAuthAuthenticationMiddleware(RequestDelegate next) { _next = next; @@ -19,8 +24,8 @@ public async Task InvokeAsync(HttpContext context) { // Skip check for metadata endpoint and other non-MCP endpoints var path = context.Request.Path; - if (path.StartsWithSegments("/.well-known") || - path.StartsWithSegments("/swagger") || + if (path.StartsWithSegments("/.well-known") || + path.StartsWithSegments("/swagger") || path.StartsWithSegments("/health") || path.StartsWithSegments("/connect") || // OpenIddict or Auth endpoints path.StartsWithSegments("/auth")) // Callback paths @@ -32,7 +37,7 @@ public async Task InvokeAsync(HttpContext context) // Check if OAuth is configured var oauthStore = context.RequestServices.GetService(); var options = context.RequestServices.GetService(); - + if (oauthStore == null || !oauthStore.GetConfigurations().Any()) { await _next(context); @@ -40,59 +45,189 @@ public async Task InvokeAsync(HttpContext context) } var accessor = context.RequestServices.GetService(); + var resourceUrl = GetResourceUrl(context, options); // Check for Authorization header string? authorization = context.Request.Headers[HeaderNames.Authorization]; if (string.IsNullOrEmpty(authorization) || !authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { - // Challenge - var resourceUrl = options?.ResourceUrlOverride; - if (string.IsNullOrWhiteSpace(resourceUrl)) - { - resourceUrl = options?.LocalEndpoints?.BaseUrlOverride; - } + // No token - return 401 challenge + await WriteChallengeResponse(context, oauthStore, resourceUrl, null, null); + return; + } - if (string.IsNullOrWhiteSpace(resourceUrl)) - { - resourceUrl = $"{context.Request.Scheme}://{context.Request.Host}"; - } + // Extract token + var token = authorization.Substring("Bearer ".Length).Trim(); + if (string.IsNullOrEmpty(token)) + { + await WriteChallengeResponse(context, oauthStore, resourceUrl, null, null); + return; + } - // Ensure resourceUrl does not end with slash for concatenation consistency, though URLs handle it. - resourceUrl = resourceUrl.TrimEnd('/'); - var metadataUrl = $"{resourceUrl}/.well-known/oauth-protected-resource"; + // Set token on accessor for downstream use + if (accessor != null) + { + accessor.AccessToken = token; + } - // Collect all scopes from OAuth configurations per MCP spec - var allScopes = oauthStore.GetConfigurations() - .SelectMany(c => c.Scopes.Keys) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); + // Perform token validation if enabled + var validationOptions = options?.TokenValidation; + if (validationOptions?.EnableJwtValidation == true) + { + var validator = context.RequestServices.GetService(); + if (validator != null) + { + var expectedAudience = validationOptions.ValidateAudience + ? (validationOptions.ExpectedAudience ?? resourceUrl) + : null; - context.Response.StatusCode = StatusCodes.Status401Unauthorized; + var validationResult = await validator.ValidateAsync(token, expectedAudience, context.RequestAborted); - // Build WWW-Authenticate header per MCP Authorization spec - // Include scope parameter when scopes are configured (RFC 6750 Section 3) - var wwwAuthenticate = $"Bearer resource_metadata=\"{metadataUrl}\""; - if (allScopes.Count > 0) - { - wwwAuthenticate += $", scope=\"{string.Join(" ", allScopes)}\""; - } - context.Response.Headers[HeaderNames.WWWAuthenticate] = wwwAuthenticate; + // Store validation result for downstream use + context.Items[TokenValidationResultKey] = validationResult; - return; - } - else - { - // Token is present, extract it to context - if (accessor != null) - { - var token = authorization.Substring("Bearer ".Length).Trim(); - if (!string.IsNullOrEmpty(token)) + if (!validationResult.IsValid) { - accessor.AccessToken = token; + // Token is invalid (expired, malformed, wrong audience) - return 401 + await WriteInvalidTokenResponse(context, oauthStore, resourceUrl, + validationResult.ErrorCode ?? "invalid_token", + validationResult.ErrorDescription ?? "Token validation failed"); + return; + } + + // Validate scopes if enabled + if (validationOptions.ValidateScopes) + { + var scopeStore = context.RequestServices.GetService(); + if (scopeStore != null) + { + // Use default validation (no specific tool name available at middleware level) + var scopeResult = scopeStore.ValidateScopesForTool("*", validationResult.Scopes); + + if (!scopeResult.IsValid) + { + // Token is valid but lacks required scopes - return 403 + await WriteInsufficientScopeResponse(context, resourceUrl, scopeResult.MissingScopes); + return; + } + } } } } await _next(context); } -} \ No newline at end of file + + private static string GetResourceUrl(HttpContext context, McpifyOptions? options) + { + var resourceUrl = options?.ResourceUrlOverride; + if (string.IsNullOrWhiteSpace(resourceUrl)) + { + resourceUrl = options?.LocalEndpoints?.BaseUrlOverride; + } + + if (string.IsNullOrWhiteSpace(resourceUrl)) + { + resourceUrl = $"{context.Request.Scheme}://{context.Request.Host}"; + } + + return resourceUrl.TrimEnd('/'); + } + + private static async Task WriteChallengeResponse( + HttpContext context, + OAuthConfigurationStore oauthStore, + string resourceUrl, + string? errorCode, + string? errorDescription) + { + var metadataUrl = $"{resourceUrl}/.well-known/oauth-protected-resource"; + + // Collect all scopes from OAuth configurations per MCP spec + var allScopes = oauthStore.GetConfigurations() + .SelectMany(c => c.Scopes.Keys) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + + // Build WWW-Authenticate header per MCP Authorization spec + var wwwAuthenticate = BuildWwwAuthenticateHeader(metadataUrl, allScopes, errorCode, errorDescription); + context.Response.Headers[HeaderNames.WWWAuthenticate] = wwwAuthenticate; + } + + private static async Task WriteInvalidTokenResponse( + HttpContext context, + OAuthConfigurationStore oauthStore, + string resourceUrl, + string errorCode, + string errorDescription) + { + var metadataUrl = $"{resourceUrl}/.well-known/oauth-protected-resource"; + + // Collect all scopes from OAuth configurations + var allScopes = oauthStore.GetConfigurations() + .SelectMany(c => c.Scopes.Keys) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + + var wwwAuthenticate = BuildWwwAuthenticateHeader(metadataUrl, allScopes, errorCode, errorDescription); + context.Response.Headers[HeaderNames.WWWAuthenticate] = wwwAuthenticate; + } + + private static async Task WriteInsufficientScopeResponse( + HttpContext context, + string resourceUrl, + IReadOnlyList requiredScopes) + { + var metadataUrl = $"{resourceUrl}/.well-known/oauth-protected-resource"; + + context.Response.StatusCode = StatusCodes.Status403Forbidden; + + // Build WWW-Authenticate header for insufficient_scope per RFC 6750 Section 3.1 + var parts = new List + { + "Bearer", + $"error=\"insufficient_scope\"", + $"error_description=\"The access token does not have the required scope(s)\"", + $"resource_metadata=\"{metadataUrl}\"" + }; + + if (requiredScopes.Count > 0) + { + parts.Add($"scope=\"{string.Join(" ", requiredScopes)}\""); + } + + context.Response.Headers[HeaderNames.WWWAuthenticate] = string.Join(", ", parts); + } + + private static string BuildWwwAuthenticateHeader( + string metadataUrl, + IReadOnlyList scopes, + string? errorCode, + string? errorDescription) + { + var parts = new List { $"Bearer resource_metadata=\"{metadataUrl}\"" }; + + if (!string.IsNullOrEmpty(errorCode)) + { + parts.Add($"error=\"{errorCode}\""); + } + + if (!string.IsNullOrEmpty(errorDescription)) + { + // Escape quotes in description + var escapedDescription = errorDescription.Replace("\"", "\\\""); + parts.Add($"error_description=\"{escapedDescription}\""); + } + + if (scopes.Count > 0) + { + parts.Add($"scope=\"{string.Join(" ", scopes)}\""); + } + + return string.Join(", ", parts); + } +} diff --git a/MCPify/Hosting/McpifyServiceExtensions.cs b/MCPify/Hosting/McpifyServiceExtensions.cs index b79f0a2..a211c98 100644 --- a/MCPify/Hosting/McpifyServiceExtensions.cs +++ b/MCPify/Hosting/McpifyServiceExtensions.cs @@ -103,6 +103,36 @@ public static IServiceCollection AddMcpify( } services.AddSingleton(oauthStore); + // Register token validation services if enabled + if (opts.TokenValidation != null) + { + services.AddSingleton(opts.TokenValidation); + + if (opts.TokenValidation.EnableJwtValidation) + { + services.AddSingleton(sp => + new JwtAccessTokenValidator(sp.GetRequiredService())); + } + + // Register scope requirement store with access to OAuth configurations + services.AddSingleton(sp => + new ScopeRequirementStore( + opts.ScopeRequirements, + sp.GetRequiredService(), + sp.GetService())); + } + else + { + // Register empty token validation options for when validation is not configured + var defaultOptions = new TokenValidationOptions(); + services.AddSingleton(defaultOptions); + services.AddSingleton(sp => + new ScopeRequirementStore( + opts.ScopeRequirements, + defaultOptions, + sp.GetService())); + } + return services; } diff --git a/README.md b/README.md index 5c0a0b5..c447c00 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,11 @@ - Includes a `login_auth_code_pkce` tool that handles the browser-based login flow automatically. - Securely stores tokens per session using encrypted local storage. - Automatically refreshes tokens when they expire. +- **MCP Authorization Spec Compliant**: Full compliance with the [MCP Authorization Specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). + - Protected Resource Metadata (`/.well-known/oauth-protected-resource`) + - RFC 8707 Resource Parameter support + - JWT token validation (expiration, audience, scopes) + - 403 Forbidden with `insufficient_scope` error - **Dual Transport**: Supports both `Stdio` (for local desktop apps like Claude) and `Http` (SSE) transports. - **Production Ready**: Robust logging, error handling, and configurable options. @@ -160,6 +165,129 @@ With `Auto` mode, MCPify detects headless environments by checking: - **Windows**: Container environments (Kubernetes, Docker) - **macOS**: SSH sessions +### Token Validation + +MCPify supports JWT token validation for enhanced security. Token validation is opt-in for backward compatibility. + +```csharp +builder.Services.AddMcpify(options => +{ + // Set the resource URL for audience validation + options.ResourceUrlOverride = "https://api.example.com"; + + // Configure OAuth (scopes defined here can be auto-required) + options.OAuthConfigurations.Add(new OAuth2Configuration + { + AuthorizationUrl = "https://auth.example.com/authorize", + TokenUrl = "https://auth.example.com/token", + Scopes = new Dictionary + { + { "read", "Read access" }, + { "write", "Write access" } + } + }); + + // Enable token validation (opt-in) + options.TokenValidation = new TokenValidationOptions + { + EnableJwtValidation = true, // Enable JWT parsing and validation + ValidateAudience = true, // Validate 'aud' claim matches resource URL + ValidateScopes = true, // Validate token has required scopes + RequireOAuthConfiguredScopes = true, // Require scopes from OAuth2Configuration + ClockSkew = TimeSpan.FromMinutes(5) // Allowed clock skew for expiration + }; +}); +``` + +**Scope Configuration Options:** + +| Option | Description | +|--------|-------------| +| `RequireOAuthConfiguredScopes = true` | Automatically require all scopes from OAuth configurations. This includes scopes defined in `OAuthConfigurations` **and** scopes discovered from OpenAPI security schemes. | +| `DefaultRequiredScopes` | Explicitly list required scopes (use when you want different scopes than what's advertised in OAuth config). | + +**Automatic Integration with OpenAPI:** When MCPify loads an external API from an OpenAPI spec that includes OAuth2 security schemes (like those configured for Swagger UI), the scopes are automatically parsed and added to the OAuth configuration store. With `RequireOAuthConfiguredScopes = true`, these scopes are automatically enforced during token validation - no duplicate configuration needed. + +When validation fails, MCPify returns appropriate HTTP responses: + +| Scenario | Status Code | WWW-Authenticate | +|----------|-------------|------------------| +| No token provided | 401 Unauthorized | `Bearer resource_metadata="..."` | +| Token expired | 401 Unauthorized | `Bearer error="invalid_token", error_description="Token has expired"` | +| Wrong audience | 401 Unauthorized | `Bearer error="invalid_token", error_description="Token audience does not match..."` | +| Missing scopes | 403 Forbidden | `Bearer error="insufficient_scope", scope="required_scope"` | + +### Per-Tool Scope Requirements + +Define granular scope requirements for specific tools using pattern matching: + +```csharp +builder.Services.AddMcpify(options => +{ + options.TokenValidation = new TokenValidationOptions + { + EnableJwtValidation = true, + ValidateScopes = true, + DefaultRequiredScopes = new List { "mcp.access" } + }; + + // Define per-tool scope requirements + options.ScopeRequirements = new List + { + // All admin_* tools require 'admin' scope + new ScopeRequirement + { + Pattern = "admin_*", + RequiredScopes = new List { "admin" } + }, + // Write operations require 'write' scope + new ScopeRequirement + { + Pattern = "*_create", + RequiredScopes = new List { "write" } + }, + new ScopeRequirement + { + Pattern = "*_update", + RequiredScopes = new List { "write" } + }, + new ScopeRequirement + { + Pattern = "*_delete", + RequiredScopes = new List { "write" } + }, + // Read-only tools need at least 'read' OR 'write' scope + new ScopeRequirement + { + Pattern = "*_get", + AnyOfScopes = new List { "read", "write" } + } + }; +}); +``` + +Pattern matching supports: +- `*` - matches any sequence of characters +- `?` - matches any single character +- Exact match - `tool_name` + +### RFC 8707 Resource Parameter + +MCPify automatically includes the [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707) `resource` parameter in OAuth requests when `ResourceUrlOverride` is configured. This helps authorization servers issue tokens scoped to specific resources: + +```csharp +builder.Services.AddMcpify(options => +{ + options.ResourceUrlOverride = "https://api.example.com"; + // ... +}); +``` + +The resource parameter is added to: +- Authorization URL (`/authorize?resource=...`) +- Token exchange requests (`POST /token` with `resource=...`) +- Token refresh requests + ## Contributing We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details. diff --git a/Sample/README.md b/Sample/README.md index bf13419..34ee212 100644 --- a/Sample/README.md +++ b/Sample/README.md @@ -7,7 +7,11 @@ This sample demonstrates how to use **MCPify** to expose ASP.NET Core endpoints - **OAuth 2.0 Provider**: An in-app OAuth 2.0 Authorization Server powered by OpenIddict, demonstrating full auth code and client credentials flows. - **Secure Endpoints**: A protected `/api/secrets` endpoint, requiring OAuth 2.0 authorization. - **External OpenAPI Integration**: Integration with the public Petstore API (`https://petstore.swagger.io/v2/swagger.json`), exposing its operations as `petstore_` prefixed MCP tools. Also demonstrates loading from a local file (`sample-api.json`) via `localfile_` tools. -- **Protected Resource Metadata**: Exposes the `/.well-known/oauth-protected-resource` endpoint for client discovery. +- **MCP Authorization Spec Compliant**: Full compliance with the [MCP Authorization Specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization): + - Protected Resource Metadata (`/.well-known/oauth-protected-resource`) + - RFC 8707 Resource Parameter in OAuth requests + - JWT token validation (expiration, audience, scopes) + - 403 Forbidden with `insufficient_scope` error - **Stdio & HTTP Transports**: Supports both Stdio for local desktop integration and HTTP (SSE) for remote access. ## Prerequisites @@ -80,7 +84,11 @@ To run the server in HTTP mode (using Server-Sent Events): This sample demonstrates how clients can authenticate with MCPify using OAuth 2.0 Authorization Code flow. -1. **Discover Authentication**: When an unauthenticated client attempts to use a protected tool (e.g., `api_secrets_get`), MCPify will respond with a `401 Unauthorized` HTTP status code and a `WWW-Authenticate` header, including `resource_metadata`. The client should then fetch this metadata. +1. **Discover Authentication**: When an unauthenticated client attempts to use a protected tool (e.g., `api_secrets_get`), MCPify will respond with: + - `401 Unauthorized` with `WWW-Authenticate: Bearer resource_metadata="..."` for missing/invalid tokens + - `403 Forbidden` with `WWW-Authenticate: Bearer error="insufficient_scope", scope="..."` for valid tokens lacking required scopes + + The client should fetch the `resource_metadata` URL to discover authorization server details. 2. **Initiate Login**: The client (e.g., Claude Desktop) will call the `login_auth_code_pkce` tool provided by MCPify. This tool returns an authorization URL. 3. **User Authorization**: The user opens the authorization URL in a browser, logs in (using the OpenIddict provider in this sample), and grants consent. 4. **Callback and Token Exchange**: After user authorization, the browser redirects to MCPify's callback endpoint (`/auth/callback`). MCPify handles the code exchange and stores the token securely for the specific session. @@ -122,6 +130,47 @@ Attach these either to `options.LocalEndpoints.AuthenticationFactory` or to a sp ### Pass-through Bearer Tokens If your MCP client already sends `Authorization: Bearer ` to the sample, MCPify will forward that token via `IMcpContextAccessor.AccessToken` instead of using stored OAuth/client-credentials tokens. +### Token Validation (Optional) + +MCPify supports JWT token validation for enhanced security. Enable it by configuring `TokenValidation`: + +```csharp +builder.Services.AddMcpify(options => +{ + options.ResourceUrlOverride = baseUrl; + + // OAuth scopes are already configured via OAuthConfigurations + // You can require these scopes automatically: + options.TokenValidation = new TokenValidationOptions + { + EnableJwtValidation = true, // Parse and validate JWT tokens + ValidateAudience = true, // Validate 'aud' claim + ValidateScopes = true, // Validate required scopes + RequireOAuthConfiguredScopes = true // Auto-require scopes from OAuth2Configuration + }; + + // Or define per-tool scope requirements for finer control + options.ScopeRequirements = new() + { + new ScopeRequirement + { + Pattern = "api_secrets_*", + RequiredScopes = new() { "read_secrets" } + } + }; +}); +``` + +**Note:** Setting `RequireOAuthConfiguredScopes = true` automatically requires all scopes from: +- Scopes defined in `OAuthConfigurations` +- Scopes discovered from OpenAPI security schemes (e.g., OAuth2 configured for Swagger UI) + +This means if your OpenAPI spec already defines OAuth2 scopes, MCPify will automatically enforce them during token validation - no duplicate configuration needed. + +When token validation is enabled, MCPify returns: +- **401 Unauthorized** with `error="invalid_token"` for expired or invalid tokens +- **403 Forbidden** with `error="insufficient_scope"` for valid tokens missing required scopes + ### Relevant configuration knobs These can be configured in `appsettings.json` or via command-line arguments. diff --git a/Tests/MCPify.Tests/Integration/OAuthMiddlewareTests.cs b/Tests/MCPify.Tests/Integration/OAuthMiddlewareTests.cs index d6d1207..b91a3e0 100644 --- a/Tests/MCPify.Tests/Integration/OAuthMiddlewareTests.cs +++ b/Tests/MCPify.Tests/Integration/OAuthMiddlewareTests.cs @@ -1,4 +1,6 @@ using System.Net; +using System.Text; +using System.Text.Json; using MCPify.Core; using MCPify.Core.Auth; using MCPify.Hosting; @@ -109,10 +111,195 @@ public async Task Request_Returns200_WhenNoOAuthConfigured() var client = host.GetTestClient(); var response = await client.GetAsync("/mcp"); - + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Request_Returns401_WhenTokenExpired_AndValidationEnabled() + { + using var host = await CreateHostAsync(services => + { + var store = services.GetRequiredService(); + store.AddConfiguration(new OAuth2Configuration { AuthorizationUrl = "https://auth" }); + }, options => + { + options.TokenValidation = new TokenValidationOptions + { + EnableJwtValidation = true, + ClockSkew = TimeSpan.Zero + }; + }); + + var client = host.GetTestClient(); + + // Create an expired JWT token + var expiredToken = CreateJwt(new + { + sub = "user123", + exp = DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeSeconds() + }); + client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", expiredToken); + + var response = await client.GetAsync("/mcp"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + var authHeader = response.Headers.WwwAuthenticate.ToString(); + Assert.Contains("error=\"invalid_token\"", authHeader); + Assert.Contains("expired", authHeader.ToLower()); + } + + [Fact] + public async Task Request_Returns401_WhenTokenAudienceDoesNotMatch() + { + using var host = await CreateHostAsync(services => + { + var store = services.GetRequiredService(); + store.AddConfiguration(new OAuth2Configuration { AuthorizationUrl = "https://auth" }); + }, options => + { + options.ResourceUrlOverride = "https://api.example.com"; + options.TokenValidation = new TokenValidationOptions + { + EnableJwtValidation = true, + ValidateAudience = true + }; + }); + + var client = host.GetTestClient(); + + // Create a JWT token with wrong audience + var token = CreateJwt(new + { + sub = "user123", + aud = "https://other-api.example.com", + exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + }); + client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + + var response = await client.GetAsync("/mcp"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + var authHeader = response.Headers.WwwAuthenticate.ToString(); + Assert.Contains("error=\"invalid_token\"", authHeader); + Assert.Contains("audience", authHeader.ToLower()); + } + + [Fact] + public async Task Request_Returns403_WhenTokenHasInsufficientScopes() + { + using var host = await CreateHostAsync(services => + { + var store = services.GetRequiredService(); + store.AddConfiguration(new OAuth2Configuration { AuthorizationUrl = "https://auth" }); + }, options => + { + options.TokenValidation = new TokenValidationOptions + { + EnableJwtValidation = true, + ValidateScopes = true, + DefaultRequiredScopes = new List { "mcp.access" } + }; + }); + + var client = host.GetTestClient(); + + // Create a JWT token without required scope + var token = CreateJwt(new + { + sub = "user123", + scope = "read write", + exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + }); + client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + + var response = await client.GetAsync("/mcp"); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var authHeader = response.Headers.WwwAuthenticate.ToString(); + Assert.Contains("error=\"insufficient_scope\"", authHeader); + Assert.Contains("mcp.access", authHeader); + } + + [Fact] + public async Task Request_Succeeds_WhenTokenHasRequiredScopes() + { + using var host = await CreateHostAsync(services => + { + var store = services.GetRequiredService(); + store.AddConfiguration(new OAuth2Configuration { AuthorizationUrl = "https://auth" }); + }, options => + { + options.TokenValidation = new TokenValidationOptions + { + EnableJwtValidation = true, + ValidateScopes = true, + DefaultRequiredScopes = new List { "mcp.access" } + }; + }); + + var client = host.GetTestClient(); + + // Create a JWT token with required scope + var token = CreateJwt(new + { + sub = "user123", + scope = "mcp.access read write", + exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + }); + client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + + var response = await client.GetAsync("/mcp"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Request_Succeeds_WhenTokenValidationDisabled() + { + using var host = await CreateHostAsync(services => + { + var store = services.GetRequiredService(); + store.AddConfiguration(new OAuth2Configuration { AuthorizationUrl = "https://auth" }); + }); + + var client = host.GetTestClient(); + + // Create an expired JWT token - should still work when validation is disabled + var expiredToken = CreateJwt(new + { + sub = "user123", + exp = DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeSeconds() + }); + client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", expiredToken); + + var response = await client.GetAsync("/mcp"); + + // Token validation is disabled by default, so expired token should be accepted Assert.Equal(HttpStatusCode.OK, response.StatusCode); } + private static string CreateJwt(object payload) + { + var header = new { alg = "HS256", typ = "JWT" }; + var headerJson = JsonSerializer.Serialize(header); + var payloadJson = JsonSerializer.Serialize(payload); + + var headerB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson)); + var payloadB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson)); + var signatureB64 = Base64UrlEncode(Encoding.UTF8.GetBytes("dummy-signature")); + + return $"{headerB64}.{payloadB64}.{signatureB64}"; + } + + private static string Base64UrlEncode(byte[] bytes) + { + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + private async Task CreateHostAsync(Action? configure = null, Action? configureOptions = null) { return await new HostBuilder() diff --git a/Tests/MCPify.Tests/Integration/OpenApiOAuthScopeIntegrationTests.cs b/Tests/MCPify.Tests/Integration/OpenApiOAuthScopeIntegrationTests.cs new file mode 100644 index 0000000..c4f1949 --- /dev/null +++ b/Tests/MCPify.Tests/Integration/OpenApiOAuthScopeIntegrationTests.cs @@ -0,0 +1,432 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using MCPify.Core; +using MCPify.Core.Auth; +using MCPify.Hosting; +using MCPify.OpenApi; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.OpenApi.Models; + +namespace MCPify.Tests.Integration; + +/// +/// Integration tests verifying that OAuth scopes from OpenAPI specs +/// are automatically enforced during token validation. +/// +public class OpenApiOAuthScopeIntegrationTests +{ + #region Test Helpers + + private static string CreateJwt(object payload) + { + var header = new { alg = "HS256", typ = "JWT" }; + var headerJson = JsonSerializer.Serialize(header); + var payloadJson = JsonSerializer.Serialize(payload); + + var headerB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson)); + var payloadB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson)); + var signatureB64 = Base64UrlEncode(Encoding.UTF8.GetBytes("dummy-signature")); + + return $"{headerB64}.{payloadB64}.{signatureB64}"; + } + + private static string Base64UrlEncode(byte[] bytes) + { + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + /// + /// Creates an OpenAPI document with OAuth2 security scheme containing specified scopes. + /// + private static OpenApiDocument CreateOpenApiDocWithOAuth(params string[] scopes) + { + var scopeDict = scopes.ToDictionary(s => s, s => $"{s} access"); + + return new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test API", Version = "1.0" }, + Paths = new OpenApiPaths + { + ["/test"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [OperationType.Get] = new OpenApiOperation + { + OperationId = "getTest", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse { Description = "OK" } + } + } + } + } + }, + Components = new OpenApiComponents + { + SecuritySchemes = new Dictionary + { + ["oauth2"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OAuth2, + Flows = new OpenApiOAuthFlows + { + AuthorizationCode = new OpenApiOAuthFlow + { + AuthorizationUrl = new Uri("https://auth.example.com/authorize"), + TokenUrl = new Uri("https://auth.example.com/token"), + Scopes = scopeDict + } + } + } + } + } + }; + } + + #endregion + + [Fact] + public void OpenApiParser_ExtractsScopes_AndAddsToStore() + { + // Arrange + var parser = new OpenApiOAuthParser(); + var store = new OAuthConfigurationStore(); + var doc = CreateOpenApiDocWithOAuth("read", "write", "admin"); + + // Act + var config = parser.Parse(doc); + if (config != null) + { + store.AddConfiguration(config); + } + + // Assert + Assert.NotNull(config); + Assert.Equal(3, config.Scopes.Count); + Assert.Contains("read", config.Scopes.Keys); + Assert.Contains("write", config.Scopes.Keys); + Assert.Contains("admin", config.Scopes.Keys); + + var storedConfigs = store.GetConfigurations().ToList(); + Assert.Single(storedConfigs); + Assert.Equal(3, storedConfigs[0].Scopes.Count); + } + + [Fact] + public void ScopeRequirementStore_UsesOpenApiScopes_WhenRequireOAuthConfiguredScopesEnabled() + { + // Arrange + var parser = new OpenApiOAuthParser(); + var oauthStore = new OAuthConfigurationStore(); + var doc = CreateOpenApiDocWithOAuth("api.read", "api.write"); + + var config = parser.Parse(doc); + oauthStore.AddConfiguration(config!); + + var options = new TokenValidationOptions + { + RequireOAuthConfiguredScopes = true + }; + var scopeStore = new ScopeRequirementStore(new List(), options, oauthStore); + + // Act & Assert - Token with all scopes passes + var validResult = scopeStore.ValidateScopesForTool("any_tool", new[] { "api.read", "api.write" }); + Assert.True(validResult.IsValid); + + // Token missing one scope fails + var invalidResult = scopeStore.ValidateScopesForTool("any_tool", new[] { "api.read" }); + Assert.False(invalidResult.IsValid); + Assert.Contains("api.write", invalidResult.MissingScopes); + + // Token with no scopes fails + var emptyResult = scopeStore.ValidateScopesForTool("any_tool", Array.Empty()); + Assert.False(emptyResult.IsValid); + Assert.Contains("api.read", emptyResult.MissingScopes); + Assert.Contains("api.write", emptyResult.MissingScopes); + } + + [Fact] + public async Task Middleware_Returns403_WhenTokenLacksOpenApiDefinedScopes() + { + // This test simulates the full flow: + // 1. OAuth config with scopes is added to store (simulating OpenAPI parsing) + // 2. Token validation is enabled with RequireOAuthConfiguredScopes + // 3. Request with token missing scopes gets 403 + + using var host = await new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddMcpify(options => + { + options.TokenValidation = new TokenValidationOptions + { + EnableJwtValidation = true, + ValidateScopes = true, + RequireOAuthConfiguredScopes = true + }; + }); + services.AddLogging(); + }) + .Configure(app => + { + // Simulate OAuth config from OpenAPI (normally done by McpifyServiceRegistrar) + var oauthStore = app.ApplicationServices.GetRequiredService(); + oauthStore.AddConfiguration(new OAuth2Configuration + { + AuthorizationUrl = "https://auth.example.com/authorize", + TokenUrl = "https://auth.example.com/token", + Scopes = new Dictionary + { + { "api.read", "Read API" }, + { "api.write", "Write API" } + } + }); + + app.UseMcpifyOAuth(); + app.Map("/mcp", b => b.Run(async c => + { + c.Response.StatusCode = 200; + await c.Response.WriteAsync("OK"); + })); + }); + }) + .StartAsync(); + + var client = host.GetTestClient(); + + // Token with only 'api.read' scope - missing 'api.write' + var token = CreateJwt(new + { + sub = "user123", + scope = "api.read", + exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + }); + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + + // Act + var response = await client.GetAsync("/mcp"); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var authHeader = response.Headers.WwwAuthenticate.ToString(); + Assert.Contains("insufficient_scope", authHeader); + Assert.Contains("api.write", authHeader); + } + + [Fact] + public async Task Middleware_Returns200_WhenTokenHasAllOpenApiDefinedScopes() + { + using var host = await new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddMcpify(options => + { + options.TokenValidation = new TokenValidationOptions + { + EnableJwtValidation = true, + ValidateScopes = true, + RequireOAuthConfiguredScopes = true + }; + }); + services.AddLogging(); + }) + .Configure(app => + { + // Simulate OAuth config from OpenAPI + var oauthStore = app.ApplicationServices.GetRequiredService(); + oauthStore.AddConfiguration(new OAuth2Configuration + { + AuthorizationUrl = "https://auth.example.com/authorize", + TokenUrl = "https://auth.example.com/token", + Scopes = new Dictionary + { + { "api.read", "Read API" }, + { "api.write", "Write API" } + } + }); + + app.UseMcpifyOAuth(); + app.Map("/mcp", b => b.Run(async c => + { + c.Response.StatusCode = 200; + await c.Response.WriteAsync("OK"); + })); + }); + }) + .StartAsync(); + + var client = host.GetTestClient(); + + // Token with all required scopes + var token = CreateJwt(new + { + sub = "user123", + scope = "api.read api.write", + exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + }); + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + + // Act + var response = await client.GetAsync("/mcp"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Middleware_IgnoresOpenApiScopes_WhenRequireOAuthConfiguredScopesDisabled() + { + using var host = await new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddMcpify(options => + { + options.TokenValidation = new TokenValidationOptions + { + EnableJwtValidation = true, + ValidateScopes = true, + RequireOAuthConfiguredScopes = false // Disabled + }; + }); + services.AddLogging(); + }) + .Configure(app => + { + // OAuth config exists but RequireOAuthConfiguredScopes is false + var oauthStore = app.ApplicationServices.GetRequiredService(); + oauthStore.AddConfiguration(new OAuth2Configuration + { + AuthorizationUrl = "https://auth.example.com/authorize", + Scopes = new Dictionary + { + { "api.read", "Read API" }, + { "api.write", "Write API" } + } + }); + + app.UseMcpifyOAuth(); + app.Map("/mcp", b => b.Run(async c => + { + c.Response.StatusCode = 200; + await c.Response.WriteAsync("OK"); + })); + }); + }) + .StartAsync(); + + var client = host.GetTestClient(); + + // Token with NO scopes - should still pass since RequireOAuthConfiguredScopes is false + var token = CreateJwt(new + { + sub = "user123", + exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + }); + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + + // Act + var response = await client.GetAsync("/mcp"); + + // Assert - Should pass because OAuth scopes are not required + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Middleware_CombinesOpenApiScopes_WithDefaultRequiredScopes() + { + using var host = await new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddMcpify(options => + { + options.TokenValidation = new TokenValidationOptions + { + EnableJwtValidation = true, + ValidateScopes = true, + RequireOAuthConfiguredScopes = true, + DefaultRequiredScopes = new List { "mcp.access" } // Additional scope + }; + }); + services.AddLogging(); + }) + .Configure(app => + { + var oauthStore = app.ApplicationServices.GetRequiredService(); + oauthStore.AddConfiguration(new OAuth2Configuration + { + AuthorizationUrl = "https://auth.example.com/authorize", + Scopes = new Dictionary + { + { "api.read", "Read API" } + } + }); + + app.UseMcpifyOAuth(); + app.Map("/mcp", b => b.Run(async c => + { + c.Response.StatusCode = 200; + await c.Response.WriteAsync("OK"); + })); + }); + }) + .StartAsync(); + + var client = host.GetTestClient(); + + // Token with OpenAPI scope but missing default scope + var tokenMissingDefault = CreateJwt(new + { + sub = "user123", + scope = "api.read", + exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + }); + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenMissingDefault); + + var response1 = await client.GetAsync("/mcp"); + Assert.Equal(HttpStatusCode.Forbidden, response1.StatusCode); + Assert.Contains("mcp.access", response1.Headers.WwwAuthenticate.ToString()); + + // Token with all scopes (OpenAPI + default) + var tokenComplete = CreateJwt(new + { + sub = "user123", + scope = "api.read mcp.access", + exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + }); + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenComplete); + + var response2 = await client.GetAsync("/mcp"); + Assert.Equal(HttpStatusCode.OK, response2.StatusCode); + } +} diff --git a/Tests/MCPify.Tests/Unit/JwtAccessTokenValidatorTests.cs b/Tests/MCPify.Tests/Unit/JwtAccessTokenValidatorTests.cs new file mode 100644 index 0000000..cd5ec6c --- /dev/null +++ b/Tests/MCPify.Tests/Unit/JwtAccessTokenValidatorTests.cs @@ -0,0 +1,351 @@ +using System.Text; +using System.Text.Json; +using MCPify.Core.Auth; + +namespace MCPify.Tests.Unit; + +public class JwtAccessTokenValidatorTests +{ + private static string CreateJwt(object payload) + { + var header = new { alg = "HS256", typ = "JWT" }; + var headerJson = JsonSerializer.Serialize(header); + var payloadJson = JsonSerializer.Serialize(payload); + + var headerB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson)); + var payloadB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson)); + + // Signature is not validated by JwtAccessTokenValidator, so we can use a dummy + var signatureB64 = Base64UrlEncode(Encoding.UTF8.GetBytes("dummy-signature")); + + return $"{headerB64}.{payloadB64}.{signatureB64}"; + } + + private static string Base64UrlEncode(byte[] bytes) + { + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + [Fact] + public async Task ValidateAsync_ExtractsScopesFromString() + { + var options = new TokenValidationOptions(); + var validator = new JwtAccessTokenValidator(options); + + var token = CreateJwt(new + { + sub = "user123", + scope = "read write admin", + exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + }); + + var result = await validator.ValidateAsync(token, null); + + Assert.True(result.IsValid); + Assert.Equal(3, result.Scopes.Count); + Assert.Contains("read", result.Scopes); + Assert.Contains("write", result.Scopes); + Assert.Contains("admin", result.Scopes); + } + + [Fact] + public async Task ValidateAsync_ExtractsScopesFromArray() + { + var options = new TokenValidationOptions(); + var validator = new JwtAccessTokenValidator(options); + + var token = CreateJwt(new + { + sub = "user123", + scope = new[] { "read", "write", "admin" }, + exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + }); + + var result = await validator.ValidateAsync(token, null); + + Assert.True(result.IsValid); + Assert.Equal(3, result.Scopes.Count); + Assert.Contains("read", result.Scopes); + Assert.Contains("write", result.Scopes); + Assert.Contains("admin", result.Scopes); + } + + [Fact] + public async Task ValidateAsync_ExtractsScopesFromScpClaim() + { + var options = new TokenValidationOptions(); + var validator = new JwtAccessTokenValidator(options); + + var token = CreateJwt(new + { + sub = "user123", + scp = "read write", + exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + }); + + var result = await validator.ValidateAsync(token, null); + + Assert.True(result.IsValid); + Assert.Equal(2, result.Scopes.Count); + Assert.Contains("read", result.Scopes); + Assert.Contains("write", result.Scopes); + } + + [Fact] + public async Task ValidateAsync_ExtractsAudienceFromString() + { + var options = new TokenValidationOptions(); + var validator = new JwtAccessTokenValidator(options); + + var token = CreateJwt(new + { + sub = "user123", + aud = "https://api.example.com", + exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + }); + + var result = await validator.ValidateAsync(token, null); + + Assert.True(result.IsValid); + Assert.Single(result.Audiences); + Assert.Equal("https://api.example.com", result.Audiences[0]); + } + + [Fact] + public async Task ValidateAsync_ExtractsAudienceFromArray() + { + var options = new TokenValidationOptions(); + var validator = new JwtAccessTokenValidator(options); + + var token = CreateJwt(new + { + sub = "user123", + aud = new[] { "https://api1.example.com", "https://api2.example.com" }, + exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + }); + + var result = await validator.ValidateAsync(token, null); + + Assert.True(result.IsValid); + Assert.Equal(2, result.Audiences.Count); + Assert.Contains("https://api1.example.com", result.Audiences); + Assert.Contains("https://api2.example.com", result.Audiences); + } + + [Fact] + public async Task ValidateAsync_FailsWhenTokenExpired() + { + var options = new TokenValidationOptions { ClockSkew = TimeSpan.Zero }; + var validator = new JwtAccessTokenValidator(options); + + var token = CreateJwt(new + { + sub = "user123", + exp = DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeSeconds() + }); + + var result = await validator.ValidateAsync(token, null); + + Assert.False(result.IsValid); + Assert.Equal("invalid_token", result.ErrorCode); + Assert.Contains("expired", result.ErrorDescription, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ValidateAsync_SucceedsWithClockSkew() + { + var options = new TokenValidationOptions { ClockSkew = TimeSpan.FromMinutes(10) }; + var validator = new JwtAccessTokenValidator(options); + + // Token expired 5 minutes ago, but clock skew is 10 minutes + var token = CreateJwt(new + { + sub = "user123", + exp = DateTimeOffset.UtcNow.AddMinutes(-5).ToUnixTimeSeconds() + }); + + var result = await validator.ValidateAsync(token, null); + + Assert.True(result.IsValid); + } + + [Fact] + public async Task ValidateAsync_ValidatesAudienceWhenEnabled() + { + var options = new TokenValidationOptions { ValidateAudience = true }; + var validator = new JwtAccessTokenValidator(options); + + var token = CreateJwt(new + { + sub = "user123", + aud = "https://api.example.com", + exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + }); + + var result = await validator.ValidateAsync(token, "https://other.example.com"); + + Assert.False(result.IsValid); + Assert.Equal("invalid_token", result.ErrorCode); + Assert.Contains("audience", result.ErrorDescription, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ValidateAsync_PassesWhenAudienceMatches() + { + var options = new TokenValidationOptions { ValidateAudience = true }; + var validator = new JwtAccessTokenValidator(options); + + var token = CreateJwt(new + { + sub = "user123", + aud = "https://api.example.com", + exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + }); + + var result = await validator.ValidateAsync(token, "https://api.example.com"); + + Assert.True(result.IsValid); + } + + [Fact] + public async Task ValidateAsync_PassesWhenAudienceMatchesInArray() + { + var options = new TokenValidationOptions { ValidateAudience = true }; + var validator = new JwtAccessTokenValidator(options); + + var token = CreateJwt(new + { + sub = "user123", + aud = new[] { "https://api1.example.com", "https://api2.example.com" }, + exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + }); + + var result = await validator.ValidateAsync(token, "https://api2.example.com"); + + Assert.True(result.IsValid); + } + + [Fact] + public async Task ValidateAsync_SkipsAudienceValidationWhenNull() + { + var options = new TokenValidationOptions { ValidateAudience = true }; + var validator = new JwtAccessTokenValidator(options); + + var token = CreateJwt(new + { + sub = "user123", + aud = "https://api.example.com", + exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + }); + + // When expectedAudience is null, validation is skipped + var result = await validator.ValidateAsync(token, null); + + Assert.True(result.IsValid); + } + + [Fact] + public async Task ValidateAsync_ExtractsSubjectAndIssuer() + { + var options = new TokenValidationOptions(); + var validator = new JwtAccessTokenValidator(options); + + var token = CreateJwt(new + { + sub = "user123", + iss = "https://auth.example.com", + exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + }); + + var result = await validator.ValidateAsync(token, null); + + Assert.True(result.IsValid); + Assert.Equal("user123", result.Subject); + Assert.Equal("https://auth.example.com", result.Issuer); + } + + [Fact] + public async Task ValidateAsync_FailsForInvalidJwtFormat() + { + var options = new TokenValidationOptions(); + var validator = new JwtAccessTokenValidator(options); + + var result = await validator.ValidateAsync("not-a-jwt", null); + + Assert.False(result.IsValid); + Assert.Equal("invalid_token", result.ErrorCode); + } + + [Fact] + public async Task ValidateAsync_FailsForInvalidBase64() + { + var options = new TokenValidationOptions(); + var validator = new JwtAccessTokenValidator(options); + + var result = await validator.ValidateAsync("header.!!!invalid-base64!!!.signature", null); + + Assert.False(result.IsValid); + Assert.Equal("invalid_token", result.ErrorCode); + } + + [Fact] + public async Task ValidateAsync_FailsForInvalidJson() + { + var options = new TokenValidationOptions(); + var validator = new JwtAccessTokenValidator(options); + + var headerB64 = Base64UrlEncode(Encoding.UTF8.GetBytes("{}")); + var payloadB64 = Base64UrlEncode(Encoding.UTF8.GetBytes("not-json")); + var token = $"{headerB64}.{payloadB64}.signature"; + + var result = await validator.ValidateAsync(token, null); + + Assert.False(result.IsValid); + Assert.Equal("invalid_token", result.ErrorCode); + } + + [Fact] + public async Task ValidateAsync_ExtractsExpirationTime() + { + var options = new TokenValidationOptions(); + var validator = new JwtAccessTokenValidator(options); + + var expTime = DateTimeOffset.UtcNow.AddHours(2); + var token = CreateJwt(new + { + sub = "user123", + exp = expTime.ToUnixTimeSeconds() + }); + + var result = await validator.ValidateAsync(token, null); + + Assert.True(result.IsValid); + Assert.NotNull(result.ExpiresAt); + // Allow 1 second tolerance for test execution time + Assert.True(Math.Abs((result.ExpiresAt.Value - expTime).TotalSeconds) < 1); + } + + [Fact] + public async Task ValidateAsync_UsesConfiguredScopeClaimName() + { + var options = new TokenValidationOptions { ScopeClaimName = "permissions" }; + var validator = new JwtAccessTokenValidator(options); + + var token = CreateJwt(new + { + sub = "user123", + permissions = "read write", + exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + }); + + var result = await validator.ValidateAsync(token, null); + + Assert.True(result.IsValid); + Assert.Equal(2, result.Scopes.Count); + Assert.Contains("read", result.Scopes); + Assert.Contains("write", result.Scopes); + } +} diff --git a/Tests/MCPify.Tests/Unit/ResourceParameterTests.cs b/Tests/MCPify.Tests/Unit/ResourceParameterTests.cs new file mode 100644 index 0000000..9ec6218 --- /dev/null +++ b/Tests/MCPify.Tests/Unit/ResourceParameterTests.cs @@ -0,0 +1,253 @@ +using MCPify.Core.Auth; +using MCPify.Core.Auth.OAuth; +using MCPify.Core.Auth.DeviceCode; +using System.Web; + +namespace MCPify.Tests.Unit; + +public class ResourceParameterTests +{ + private readonly InMemoryTokenStore _tokenStore = new(); + private readonly MockMcpContextAccessor _contextAccessor = new(); + + #region OAuthAuthorizationCodeAuthentication Tests + + [Fact] + public void BuildAuthorizationUrl_IncludesResourceParameter_WhenConfigured() + { + var resourceUrl = "https://api.example.com"; + var auth = new OAuthAuthorizationCodeAuthentication( + clientId: "client-id", + authorizationEndpoint: "https://auth.example.com/authorize", + tokenEndpoint: "https://auth.example.com/token", + scope: "read write", + secureTokenStore: _tokenStore, + mcpContextAccessor: _contextAccessor, + redirectUri: "https://app.example.com/callback", + resourceUrl: resourceUrl + ); + + _contextAccessor.SessionId = "test-session"; + + var url = auth.BuildAuthorizationUrl("test-session"); + var uri = new Uri(url); + var query = HttpUtility.ParseQueryString(uri.Query); + + Assert.Equal(resourceUrl, query["resource"]); + } + + [Fact] + public void BuildAuthorizationUrl_OmitsResourceParameter_WhenNotConfigured() + { + var auth = new OAuthAuthorizationCodeAuthentication( + clientId: "client-id", + authorizationEndpoint: "https://auth.example.com/authorize", + tokenEndpoint: "https://auth.example.com/token", + scope: "read write", + secureTokenStore: _tokenStore, + mcpContextAccessor: _contextAccessor, + redirectUri: "https://app.example.com/callback" + ); + + _contextAccessor.SessionId = "test-session"; + + var url = auth.BuildAuthorizationUrl("test-session"); + var uri = new Uri(url); + var query = HttpUtility.ParseQueryString(uri.Query); + + Assert.Null(query["resource"]); + } + + [Fact] + public void BuildAuthorizationUrl_IncludesAllRequiredParameters() + { + var auth = new OAuthAuthorizationCodeAuthentication( + clientId: "my-client", + authorizationEndpoint: "https://auth.example.com/authorize", + tokenEndpoint: "https://auth.example.com/token", + scope: "openid profile", + secureTokenStore: _tokenStore, + mcpContextAccessor: _contextAccessor, + redirectUri: "https://app.example.com/callback", + usePkce: true, + resourceUrl: "https://api.example.com" + ); + + _contextAccessor.SessionId = "test-session"; + + var url = auth.BuildAuthorizationUrl("test-session"); + var uri = new Uri(url); + var query = HttpUtility.ParseQueryString(uri.Query); + + Assert.Equal("code", query["response_type"]); + Assert.Equal("my-client", query["client_id"]); + Assert.Equal("https://app.example.com/callback", query["redirect_uri"]); + Assert.Equal("openid profile", query["scope"]); + Assert.NotNull(query["state"]); + Assert.NotNull(query["code_challenge"]); + Assert.Equal("S256", query["code_challenge_method"]); + Assert.Equal("https://api.example.com", query["resource"]); + } + + #endregion + + #region ClientCredentialsAuthentication Tests + + [Fact] + public async Task ClientCredentials_IncludesResourceParameter_WhenConfigured() + { + var resourceUrl = "https://api.example.com"; + var capturedContent = (FormUrlEncodedContent?)null; + + var mockHandler = new MockHttpMessageHandler(request => + { + capturedContent = request.Content as FormUrlEncodedContent; + return Task.FromResult(new HttpResponseMessage + { + StatusCode = System.Net.HttpStatusCode.OK, + Content = new StringContent("{\"access_token\":\"token\",\"expires_in\":3600}") + }); + }); + + var httpClient = new HttpClient(mockHandler); + + var auth = new ClientCredentialsAuthentication( + clientId: "client-id", + clientSecret: "client-secret", + tokenEndpoint: "https://auth.example.com/token", + scope: "read write", + secureTokenStore: _tokenStore, + mcpContextAccessor: _contextAccessor, + httpClient: httpClient, + resourceUrl: resourceUrl + ); + + _contextAccessor.SessionId = "test-session"; + + var request = new HttpRequestMessage(HttpMethod.Get, "https://api.example.com/data"); + await auth.ApplyAsync(request); + + Assert.NotNull(capturedContent); + var formData = await capturedContent.ReadAsStringAsync(); + Assert.Contains($"resource={Uri.EscapeDataString(resourceUrl)}", formData); + } + + [Fact] + public async Task ClientCredentials_OmitsResourceParameter_WhenNotConfigured() + { + var capturedContent = (FormUrlEncodedContent?)null; + + var mockHandler = new MockHttpMessageHandler(request => + { + capturedContent = request.Content as FormUrlEncodedContent; + return Task.FromResult(new HttpResponseMessage + { + StatusCode = System.Net.HttpStatusCode.OK, + Content = new StringContent("{\"access_token\":\"token\",\"expires_in\":3600}") + }); + }); + + var httpClient = new HttpClient(mockHandler); + + var auth = new ClientCredentialsAuthentication( + clientId: "client-id", + clientSecret: "client-secret", + tokenEndpoint: "https://auth.example.com/token", + scope: "read write", + secureTokenStore: _tokenStore, + mcpContextAccessor: _contextAccessor, + httpClient: httpClient + ); + + _contextAccessor.SessionId = "test-session"; + + var request = new HttpRequestMessage(HttpMethod.Get, "https://api.example.com/data"); + await auth.ApplyAsync(request); + + Assert.NotNull(capturedContent); + var formData = await capturedContent.ReadAsStringAsync(); + Assert.DoesNotContain("resource=", formData); + } + + #endregion + + #region DeviceCodeAuthentication Tests + + [Fact] + public async Task DeviceCode_IncludesResourceParameter_InDeviceCodeRequest() + { + var resourceUrl = "https://api.example.com"; + var requestCount = 0; + var capturedDeviceCodeContent = (FormUrlEncodedContent?)null; + + var mockHandler = new MockHttpMessageHandler(request => + { + requestCount++; + if (requestCount == 1) + { + // Device code request + capturedDeviceCodeContent = request.Content as FormUrlEncodedContent; + return Task.FromResult(new HttpResponseMessage + { + StatusCode = System.Net.HttpStatusCode.OK, + Content = new StringContent("{\"device_code\":\"dc\",\"user_code\":\"UC\",\"verification_uri\":\"https://auth.example.com/device\",\"expires_in\":600,\"interval\":1}") + }); + } + else + { + // Token polling request + return Task.FromResult(new HttpResponseMessage + { + StatusCode = System.Net.HttpStatusCode.OK, + Content = new StringContent("{\"access_token\":\"token\",\"refresh_token\":\"rt\",\"expires_in\":3600}") + }); + } + }); + + var httpClient = new HttpClient(mockHandler); + var userPromptCalled = false; + + var auth = new DeviceCodeAuthentication( + clientId: "client-id", + deviceCodeEndpoint: "https://auth.example.com/device/code", + tokenEndpoint: "https://auth.example.com/token", + scope: "read write", + secureTokenStore: _tokenStore, + mcpContextAccessor: _contextAccessor, + userPrompt: (_, _) => { userPromptCalled = true; return Task.CompletedTask; }, + httpClient: httpClient, + resourceUrl: resourceUrl + ); + + _contextAccessor.SessionId = "test-session"; + + var request = new HttpRequestMessage(HttpMethod.Get, "https://api.example.com/data"); + await auth.ApplyAsync(request); + + Assert.True(userPromptCalled); + Assert.NotNull(capturedDeviceCodeContent); + var formData = await capturedDeviceCodeContent.ReadAsStringAsync(); + Assert.Contains($"resource={Uri.EscapeDataString(resourceUrl)}", formData); + } + + #endregion + + #region Helper Classes + + private class MockHttpMessageHandler : HttpMessageHandler + { + private readonly Func> _handler; + + public MockHttpMessageHandler(Func> handler) + { + _handler = handler; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return _handler(request); + } + } + + #endregion +} diff --git a/Tests/MCPify.Tests/Unit/ScopeRequirementTests.cs b/Tests/MCPify.Tests/Unit/ScopeRequirementTests.cs new file mode 100644 index 0000000..78d955b --- /dev/null +++ b/Tests/MCPify.Tests/Unit/ScopeRequirementTests.cs @@ -0,0 +1,346 @@ +using MCPify.Core.Auth; + +namespace MCPify.Tests.Unit; + +public class ScopeRequirementTests +{ + #region ScopeRequirement Pattern Matching Tests + + [Theory] + [InlineData("admin_users", "admin_users", true)] + [InlineData("admin_users", "admin_roles", false)] + [InlineData("admin_*", "admin_users", true)] + [InlineData("admin_*", "admin_roles", true)] + [InlineData("admin_*", "user_admin", false)] + [InlineData("*_admin", "user_admin", true)] + [InlineData("*_admin", "admin_users", false)] + [InlineData("api_?et_users", "api_get_users", true)] + [InlineData("api_?et_users", "api_set_users", true)] + [InlineData("api_?et_users", "api_delete_users", false)] + [InlineData("*", "anything", true)] + [InlineData("tool_*_admin", "tool_user_admin", true)] + [InlineData("tool_*_admin", "tool_role_admin", true)] + [InlineData("tool_*_admin", "tool_admin", false)] + public void Matches_WorksWithPatterns(string pattern, string toolName, bool expected) + { + var requirement = new ScopeRequirement { Pattern = pattern }; + Assert.Equal(expected, requirement.Matches(toolName)); + } + + [Fact] + public void Matches_IsCaseInsensitive() + { + var requirement = new ScopeRequirement { Pattern = "Admin_*" }; + + Assert.True(requirement.Matches("admin_users")); + Assert.True(requirement.Matches("ADMIN_ROLES")); + Assert.True(requirement.Matches("Admin_Tools")); + } + + #endregion + + #region ScopeRequirement Scope Validation Tests + + [Fact] + public void IsSatisfiedBy_RequiresAllRequiredScopes() + { + var requirement = new ScopeRequirement + { + Pattern = "admin_*", + RequiredScopes = new List { "admin", "write" } + }; + + Assert.True(requirement.IsSatisfiedBy(new[] { "admin", "write", "read" })); + Assert.False(requirement.IsSatisfiedBy(new[] { "admin", "read" })); + Assert.False(requirement.IsSatisfiedBy(new[] { "write", "read" })); + Assert.False(requirement.IsSatisfiedBy(new[] { "read" })); + } + + [Fact] + public void IsSatisfiedBy_RequiresAnyOfScopes() + { + var requirement = new ScopeRequirement + { + Pattern = "api_*", + AnyOfScopes = new List { "read", "write" } + }; + + Assert.True(requirement.IsSatisfiedBy(new[] { "read" })); + Assert.True(requirement.IsSatisfiedBy(new[] { "write" })); + Assert.True(requirement.IsSatisfiedBy(new[] { "read", "write" })); + Assert.False(requirement.IsSatisfiedBy(new[] { "admin" })); + Assert.False(requirement.IsSatisfiedBy(Array.Empty())); + } + + [Fact] + public void IsSatisfiedBy_CombinesRequiredAndAnyOf() + { + var requirement = new ScopeRequirement + { + Pattern = "admin_*", + RequiredScopes = new List { "admin" }, + AnyOfScopes = new List { "read", "write" } + }; + + Assert.True(requirement.IsSatisfiedBy(new[] { "admin", "read" })); + Assert.True(requirement.IsSatisfiedBy(new[] { "admin", "write" })); + Assert.True(requirement.IsSatisfiedBy(new[] { "admin", "read", "write" })); + Assert.False(requirement.IsSatisfiedBy(new[] { "admin" })); // Missing any of read/write + Assert.False(requirement.IsSatisfiedBy(new[] { "read" })); // Missing required admin + Assert.False(requirement.IsSatisfiedBy(new[] { "read", "write" })); // Missing required admin + } + + [Fact] + public void IsSatisfiedBy_IsCaseInsensitive() + { + var requirement = new ScopeRequirement + { + Pattern = "api_*", + RequiredScopes = new List { "Admin" }, + AnyOfScopes = new List { "Read" } + }; + + Assert.True(requirement.IsSatisfiedBy(new[] { "ADMIN", "read" })); + Assert.True(requirement.IsSatisfiedBy(new[] { "admin", "READ" })); + } + + [Fact] + public void IsSatisfiedBy_PassesWithEmptyRequirements() + { + var requirement = new ScopeRequirement { Pattern = "api_*" }; + + Assert.True(requirement.IsSatisfiedBy(Array.Empty())); + Assert.True(requirement.IsSatisfiedBy(new[] { "any", "scope" })); + } + + [Fact] + public void GetAllRequiredScopes_ReturnsAllScopes() + { + var requirement = new ScopeRequirement + { + Pattern = "api_*", + RequiredScopes = new List { "admin", "write" }, + AnyOfScopes = new List { "read", "execute" } + }; + + var allScopes = requirement.GetAllRequiredScopes().ToList(); + + Assert.Equal(4, allScopes.Count); + Assert.Contains("admin", allScopes); + Assert.Contains("write", allScopes); + Assert.Contains("read", allScopes); + Assert.Contains("execute", allScopes); + } + + #endregion + + #region ScopeRequirementStore Tests + + [Fact] + public void GetRequirementsForTool_ReturnsMatchingRequirements() + { + var options = new TokenValidationOptions(); + var requirements = new List + { + new() { Pattern = "admin_*", RequiredScopes = new List { "admin" } }, + new() { Pattern = "api_*", RequiredScopes = new List { "api" } }, + new() { Pattern = "*_users", RequiredScopes = new List { "users" } } + }; + var store = new ScopeRequirementStore(requirements, options); + + var adminUsersReqs = store.GetRequirementsForTool("admin_users").ToList(); + Assert.Equal(2, adminUsersReqs.Count); // Matches "admin_*" and "*_users" + + var apiGetReqs = store.GetRequirementsForTool("api_get").ToList(); + Assert.Single(apiGetReqs); // Matches only "api_*" + + var otherReqs = store.GetRequirementsForTool("other_tool").ToList(); + Assert.Empty(otherReqs); + } + + [Fact] + public void ValidateScopesForTool_ChecksDefaultScopes() + { + var options = new TokenValidationOptions + { + DefaultRequiredScopes = new List { "mcp.access" } + }; + var store = new ScopeRequirementStore(new List(), options); + + var result = store.ValidateScopesForTool("any_tool", new[] { "mcp.access" }); + Assert.True(result.IsValid); + + var failResult = store.ValidateScopesForTool("any_tool", new[] { "other" }); + Assert.False(failResult.IsValid); + Assert.Contains("mcp.access", failResult.MissingScopes); + } + + [Fact] + public void ValidateScopesForTool_ChecksToolSpecificScopes() + { + var options = new TokenValidationOptions(); + var requirements = new List + { + new() { Pattern = "admin_*", RequiredScopes = new List { "admin" } } + }; + var store = new ScopeRequirementStore(requirements, options); + + var result = store.ValidateScopesForTool("admin_users", new[] { "admin" }); + Assert.True(result.IsValid); + + var failResult = store.ValidateScopesForTool("admin_users", new[] { "user" }); + Assert.False(failResult.IsValid); + Assert.Contains("admin", failResult.MissingScopes); + } + + [Fact] + public void ValidateScopesForTool_CombinesDefaultAndToolSpecificScopes() + { + var options = new TokenValidationOptions + { + DefaultRequiredScopes = new List { "mcp.access" } + }; + var requirements = new List + { + new() { Pattern = "admin_*", RequiredScopes = new List { "admin" } } + }; + var store = new ScopeRequirementStore(requirements, options); + + var result = store.ValidateScopesForTool("admin_users", new[] { "mcp.access", "admin" }); + Assert.True(result.IsValid); + + var failResult = store.ValidateScopesForTool("admin_users", new[] { "mcp.access" }); + Assert.False(failResult.IsValid); + Assert.Contains("admin", failResult.MissingScopes); + + var failResult2 = store.ValidateScopesForTool("admin_users", new[] { "admin" }); + Assert.False(failResult2.IsValid); + Assert.Contains("mcp.access", failResult2.MissingScopes); + } + + [Fact] + public void GetRequiredScopesForTool_ReturnsAllRequiredScopes() + { + var options = new TokenValidationOptions + { + DefaultRequiredScopes = new List { "mcp.access" } + }; + var requirements = new List + { + new() { Pattern = "admin_*", RequiredScopes = new List { "admin" }, AnyOfScopes = new List { "read", "write" } } + }; + var store = new ScopeRequirementStore(requirements, options); + + var scopes = store.GetRequiredScopesForTool("admin_users").ToList(); + + Assert.Contains("mcp.access", scopes); + Assert.Contains("admin", scopes); + Assert.Contains("read", scopes); + Assert.Contains("write", scopes); + } + + [Fact] + public void ValidateScopesForTool_HandlesMultipleMatchingRequirements() + { + var options = new TokenValidationOptions(); + var requirements = new List + { + new() { Pattern = "admin_*", RequiredScopes = new List { "admin" } }, + new() { Pattern = "*_users", RequiredScopes = new List { "users.manage" } } + }; + var store = new ScopeRequirementStore(requirements, options); + + // admin_users matches both patterns, so both scopes are required + var result = store.ValidateScopesForTool("admin_users", new[] { "admin", "users.manage" }); + Assert.True(result.IsValid); + + var failResult = store.ValidateScopesForTool("admin_users", new[] { "admin" }); + Assert.False(failResult.IsValid); + Assert.Contains("users.manage", failResult.MissingScopes); + } + + [Fact] + public void ValidateScopesForTool_UsesOAuthConfiguredScopes_WhenEnabled() + { + var oauthStore = new OAuthConfigurationStore(); + oauthStore.AddConfiguration(new OAuth2Configuration + { + AuthorizationUrl = "https://auth", + Scopes = new Dictionary + { + { "read", "Read access" }, + { "write", "Write access" } + } + }); + + var options = new TokenValidationOptions + { + RequireOAuthConfiguredScopes = true + }; + var store = new ScopeRequirementStore(new List(), options, oauthStore); + + // Token with all OAuth-configured scopes should pass + var result = store.ValidateScopesForTool("any_tool", new[] { "read", "write" }); + Assert.True(result.IsValid); + + // Token missing one OAuth-configured scope should fail + var failResult = store.ValidateScopesForTool("any_tool", new[] { "read" }); + Assert.False(failResult.IsValid); + Assert.Contains("write", failResult.MissingScopes); + } + + [Fact] + public void ValidateScopesForTool_IgnoresOAuthScopes_WhenDisabled() + { + var oauthStore = new OAuthConfigurationStore(); + oauthStore.AddConfiguration(new OAuth2Configuration + { + AuthorizationUrl = "https://auth", + Scopes = new Dictionary + { + { "read", "Read access" }, + { "write", "Write access" } + } + }); + + var options = new TokenValidationOptions + { + RequireOAuthConfiguredScopes = false // disabled by default + }; + var store = new ScopeRequirementStore(new List(), options, oauthStore); + + // Token with no scopes should pass when OAuth scope checking is disabled + var result = store.ValidateScopesForTool("any_tool", Array.Empty()); + Assert.True(result.IsValid); + } + + [Fact] + public void GetRequiredScopesForTool_IncludesOAuthScopes_WhenEnabled() + { + var oauthStore = new OAuthConfigurationStore(); + oauthStore.AddConfiguration(new OAuth2Configuration + { + AuthorizationUrl = "https://auth", + Scopes = new Dictionary + { + { "api.read", "Read API" }, + { "api.write", "Write API" } + } + }); + + var options = new TokenValidationOptions + { + RequireOAuthConfiguredScopes = true, + DefaultRequiredScopes = new List { "mcp.access" } + }; + var store = new ScopeRequirementStore(new List(), options, oauthStore); + + var scopes = store.GetRequiredScopesForTool("any_tool").ToList(); + + Assert.Contains("mcp.access", scopes); + Assert.Contains("api.read", scopes); + Assert.Contains("api.write", scopes); + } + + #endregion +}