diff --git a/src/Exceptionless.Core/Services/OAuthClientMetadataService.cs b/src/Exceptionless.Core/Services/OAuthClientMetadataService.cs index b2b57c5aa7..6253553c01 100644 --- a/src/Exceptionless.Core/Services/OAuthClientMetadataService.cs +++ b/src/Exceptionless.Core/Services/OAuthClientMetadataService.cs @@ -14,7 +14,7 @@ public interface IOAuthClientMetadataService Task GetClientMetadataAsync(string clientId); } -public sealed class OAuthClientMetadataService(HttpClient httpClient, OAuthServerOptions options, ICacheClient cacheClient, ILogger logger) : IOAuthClientMetadataService +public sealed class OAuthClientMetadataService(HttpClient httpClient, OAuthServerOptions options, ICacheClient cacheClient, ILogger logger, TimeProvider timeProvider) : IOAuthClientMetadataService { private const string CachePrefix = "oauth:cimd:"; private const string FailureCachePrefix = "oauth:cimd-failure:"; @@ -54,7 +54,10 @@ public sealed class OAuthClientMetadataService(HttpClient httpClient, OAuthServe if (metadata is null) return await CacheFailureAsync(failureCacheKey); - await cacheClient.SetAsync(cacheKey, metadata, options.ClientMetadataDocumentCacheLifetime); + var cacheLifetime = GetCacheLifetime(response); + if (cacheLifetime.HasValue) + await cacheClient.SetAsync(cacheKey, metadata, cacheLifetime.Value); + return metadata; } catch (OperationCanceledException) @@ -97,10 +100,34 @@ public static bool TryCreateClientMetadataDocumentUri(string clientId, out Uri u if (!String.IsNullOrEmpty(parsedUri.Fragment) || !String.IsNullOrEmpty(parsedUri.UserInfo)) return false; + if (String.IsNullOrWhiteSpace(parsedUri.AbsolutePath.Trim('/'))) + return false; + uri = parsedUri; return true; } + private TimeSpan? GetCacheLifetime(HttpResponseMessage response) + { + var cacheControl = response.Headers.CacheControl; + if (cacheControl is { NoStore: true } or { NoCache: true }) + return null; + + TimeSpan responseAge = response.Headers.Age ?? TimeSpan.Zero; + TimeSpan? cacheLifetime = cacheControl?.MaxAge - responseAge; + if (!cacheLifetime.HasValue && response.Content.Headers.Expires.HasValue) + cacheLifetime = response.Content.Headers.Expires.Value - timeProvider.GetUtcNow(); + + cacheLifetime ??= options.ClientMetadataDocumentCacheLifetime - responseAge; + + if (cacheLifetime <= TimeSpan.Zero || options.ClientMetadataDocumentCacheLifetime <= TimeSpan.Zero) + return null; + + return cacheLifetime < options.ClientMetadataDocumentCacheLifetime + ? cacheLifetime + : options.ClientMetadataDocumentCacheLifetime; + } + private static async Task ReadLimitedAsync(Stream stream, int maxBytes, CancellationToken cancellationToken) { byte[] buffer = new byte[8192]; diff --git a/src/Exceptionless.Core/Services/OAuthService.cs b/src/Exceptionless.Core/Services/OAuthService.cs index bc77529109..6ac1a8c32c 100644 --- a/src/Exceptionless.Core/Services/OAuthService.cs +++ b/src/Exceptionless.Core/Services/OAuthService.cs @@ -28,12 +28,10 @@ public class OAuthService(OAuthServerOptions options, ICacheClient cacheClient, AuthorizationRoles.ProjectsRead, AuthorizationRoles.StacksRead, AuthorizationRoles.StacksWrite, - AuthorizationRoles.EventsRead, - AuthorizationRoles.OfflineAccess + AuthorizationRoles.EventsRead ], [ - AuthorizationRoles.McpRead, - AuthorizationRoles.OfflineAccess + AuthorizationRoles.McpRead ]); public static readonly OAuthResourceDefinition RestApiResource = new("/api/v2", @@ -242,6 +240,9 @@ private bool TryCreateObservedApplication(string clientId, OAuthClientMetadataDo if (!String.Equals(metadata.ClientId, clientId, StringComparison.Ordinal)) return false; + if (String.IsNullOrWhiteSpace(metadata.ClientName)) + return false; + if (metadata.GrantTypes is { Length: > 0 } && !metadata.GrantTypes.Contains(OAuthGrantTypes.AuthorizationCode, StringComparer.Ordinal)) return false; @@ -251,15 +252,12 @@ private bool TryCreateObservedApplication(string clientId, OAuthClientMetadataDo if (!String.IsNullOrWhiteSpace(metadata.TokenEndpointAuthMethod) && !String.Equals(metadata.TokenEndpointAuthMethod, "none", StringComparison.Ordinal)) return false; - string[] redirectUris = metadata.RedirectUris? - .Where(OAuthApplication.IsValidRedirectUri) - .Distinct(StringComparer.Ordinal) - .Take(20) - .ToArray() ?? []; - - if (redirectUris.Length == 0) + if (metadata.RedirectUris is not { Length: > 0 and <= 20 } + || metadata.RedirectUris.Any(uri => !OAuthApplication.IsValidRedirectUri(uri))) return false; + string[] redirectUris = metadata.RedirectUris.Distinct(StringComparer.Ordinal).ToArray(); + var metadataScopes = NormalizeScopes(metadata.Scope); string[] scopes = metadataScopes.Count > 0 ? metadataScopes.Where(s => SupportedScopes.Contains(s, StringComparer.Ordinal)).Distinct(StringComparer.Ordinal).ToArray() diff --git a/src/Exceptionless.Web/Api/Handlers/OAuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/OAuthHandler.cs index b59b7d2289..d8738a7235 100644 --- a/src/Exceptionless.Web/Api/Handlers/OAuthHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/OAuthHandler.cs @@ -39,7 +39,8 @@ public Task Handle(GetAuthorizationServerMetadata message) TokenEndpointAuthMethodsSupported = ["none"], ScopesSupported = OAuthService.SupportedScopes, ResourceDocumentation = $"{origin}/mcp", - ClientIdMetadataDocumentSupported = oauthService.ClientIdMetadataDocumentSupported + ClientIdMetadataDocumentSupported = oauthService.ClientIdMetadataDocumentSupported, + AuthorizationResponseIssParameterSupported = true })); } @@ -193,7 +194,7 @@ private async Task CompleteAuthorizationAsync(OAuthAuthorizeRequest req return OAuthError("invalid_request", organizationValidation.ErrorDescription); string code = await oauthService.CreateAuthorizationCodeAsync(request, HttpContext.Request.GetUser().Id, organizationValidation.OrganizationIds); - string redirectUri = BuildRedirectUri(request.RedirectUri, code, request.State); + string redirectUri = BuildRedirectUri(request.RedirectUri, code, request.State, GetOrigin()); return HttpResults.Ok(new OAuthAuthorizeResponse { RedirectUri = redirectUri }); } @@ -210,12 +211,13 @@ private OrganizationValidationResult ValidateRequestedOrganizations(IReadOnlyCol return OrganizationValidationResult.Valid(organizationIds); } - private static string BuildRedirectUri(string redirectUri, string code, string? state) + private static string BuildRedirectUri(string redirectUri, string code, string? state, string issuer) { var redirect = new UriBuilder(redirectUri); var query = QueryHelpers.ParseQuery(redirect.Query); var parameters = query.ToDictionary(kvp => kvp.Key, kvp => (string?)kvp.Value.ToString()); parameters["code"] = code; + parameters["iss"] = issuer; if (!String.IsNullOrEmpty(state)) parameters["state"] = state; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index 9052e6db6c..525f374f72 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -212,6 +212,7 @@ export interface OAuthAuthorizationServerMetadata { scopes_supported: string[]; resource_documentation: string; client_id_metadata_document_supported: boolean; + authorization_response_iss_parameter_supported: boolean; } export interface OAuthAuthorizeConsentResponse { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index 576e371b0a..bafcb570bd 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -301,6 +301,7 @@ export const OAuthAuthorizationServerMetadataSchema = object({ scopes_supported: array(string()), resource_documentation: string().min(1, "Resource documentation is required"), client_id_metadata_document_supported: boolean(), + authorization_response_iss_parameter_supported: boolean(), }); export type OAuthAuthorizationServerMetadataFormData = Infer< typeof OAuthAuthorizationServerMetadataSchema diff --git a/src/Exceptionless.Web/Mcp/McpOriginValidationMiddleware.cs b/src/Exceptionless.Web/Mcp/McpOriginValidationMiddleware.cs new file mode 100644 index 0000000000..6499717cc2 --- /dev/null +++ b/src/Exceptionless.Web/Mcp/McpOriginValidationMiddleware.cs @@ -0,0 +1,43 @@ +using Exceptionless.Core; +using Exceptionless.Core.Services; +using Microsoft.Net.Http.Headers; + +namespace Exceptionless.Web.Mcp; + +public sealed class McpOriginValidationMiddleware(RequestDelegate next, AppOptions appOptions) +{ + private readonly string _canonicalOrigin = new Uri(appOptions.BaseURL).GetLeftPart(UriPartial.Authority); + + public async Task InvokeAsync(HttpContext context) + { + if (!context.Request.Path.StartsWithSegments(new PathString(OAuthService.McpResource.Path), StringComparison.OrdinalIgnoreCase) + || !context.Request.Headers.TryGetValue(HeaderNames.Origin, out var origins)) + { + await next(context); + return; + } + + if (origins.Count != 1 || !IsAllowedOrigin(origins[0], _canonicalOrigin)) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + + await next(context); + } + + internal static bool IsAllowedOrigin(string? origin, string canonicalOrigin) + { + if (String.IsNullOrWhiteSpace(origin) + || !Uri.TryCreate(origin, UriKind.Absolute, out var originUri) + || !String.IsNullOrEmpty(originUri.UserInfo) + || !String.IsNullOrEmpty(originUri.Query) + || !String.IsNullOrEmpty(originUri.Fragment) + || !String.Equals(originUri.AbsolutePath, "/", StringComparison.Ordinal)) + { + return false; + } + + return String.Equals(originUri.GetLeftPart(UriPartial.Authority), canonicalOrigin, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Exceptionless.Web/Models/OAuth/OAuthModels.cs b/src/Exceptionless.Web/Models/OAuth/OAuthModels.cs index 14dc6bb595..97f40672e9 100644 --- a/src/Exceptionless.Web/Models/OAuth/OAuthModels.cs +++ b/src/Exceptionless.Web/Models/OAuth/OAuthModels.cs @@ -114,6 +114,9 @@ public sealed record OAuthAuthorizationServerMetadata [JsonPropertyName("client_id_metadata_document_supported")] public bool ClientIdMetadataDocumentSupported { get; init; } + + [JsonPropertyName("authorization_response_iss_parameter_supported")] + public bool AuthorizationResponseIssParameterSupported { get; init; } } public sealed record OAuthProtectedResourceMetadata diff --git a/src/Exceptionless.Web/Program.cs b/src/Exceptionless.Web/Program.cs index 6066b5e94a..4a7cacf469 100644 --- a/src/Exceptionless.Web/Program.cs +++ b/src/Exceptionless.Web/Program.cs @@ -333,6 +333,7 @@ ApplicationException applicationException when applicationException.Message.Cont app.UseDefaultFiles(); app.UseFileServer(); app.UseRouting(); + app.UseMiddleware(); app.UseCors("AllowAny"); app.UseHttpMethodOverride(); app.UseForwardedHeaders(); diff --git a/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs b/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs index e84b15e0d4..1511233242 100644 --- a/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs +++ b/src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs @@ -160,7 +160,25 @@ protected override async Task HandleChallengeAsync(AuthenticationProperties prop if (!TryGetOAuthResourceForRequest(out var resourceDefinition, out _)) return; - Response.Headers.WWWAuthenticate = $"Bearer resource_metadata=\"{GetCanonicalOrigin()}/.well-known/oauth-protected-resource{resourceDefinition.Path}\""; + string challenge = $"Bearer resource_metadata=\"{GetResourceMetadataUri(resourceDefinition)}\""; + if (resourceDefinition.RequiredScopes.Count > 0) + challenge += $", scope=\"{String.Join(' ', resourceDefinition.RequiredScopes)}\""; + + Response.Headers.WWWAuthenticate = challenge; + } + + protected override async Task HandleForbiddenAsync(AuthenticationProperties properties) + { + await base.HandleForbiddenAsync(properties); + + if (!IsOAuthBearerRequest() + || !TryGetOAuthResourceForRequest(out var resourceDefinition, out _) + || resourceDefinition.RequiredScopes.Count == 0) + { + return; + } + + Response.Headers.WWWAuthenticate = $"Bearer error=\"insufficient_scope\", scope=\"{String.Join(' ', resourceDefinition.RequiredScopes)}\", resource_metadata=\"{GetResourceMetadataUri(resourceDefinition)}\""; } private async Task AuthenticateOAuthBearerAsync(string token) @@ -331,6 +349,19 @@ private bool IsMcpRequest() return Request.Path.StartsWithSegments(new PathString(OAuthService.McpResource.Path), StringComparison.OrdinalIgnoreCase); } + private bool IsOAuthBearerRequest() + { + string? authHeaderValue = Request.Headers.TryGetAndReturn("Authorization").FirstOrDefault(); + return AuthenticationHeaderValue.TryParse(authHeaderValue, out var authHeader) + && String.Equals(authHeader.Scheme, BearerScheme, StringComparison.OrdinalIgnoreCase) + && OAuthService.IsOAuthTokenFormat(authHeader.Parameter); + } + + private string GetResourceMetadataUri(OAuthResourceDefinition resourceDefinition) + { + return $"{GetCanonicalOrigin()}/.well-known/oauth-protected-resource{resourceDefinition.Path}"; + } + private string GetCanonicalOrigin() { return new Uri(_appOptions.BaseURL).GetLeftPart(UriPartial.Authority); diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 7e06a081c7..dc702c7432 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -11963,7 +11963,8 @@ "token_endpoint_auth_methods_supported", "scopes_supported", "resource_documentation", - "client_id_metadata_document_supported" + "client_id_metadata_document_supported", + "authorization_response_iss_parameter_supported" ], "type": "object", "properties": { @@ -12017,6 +12018,9 @@ }, "client_id_metadata_document_supported": { "type": "boolean" + }, + "authorization_response_iss_parameter_supported": { + "type": "boolean" } } }, diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs index 00ca00733f..72b642b4f0 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs @@ -30,6 +30,7 @@ public sealed class OAuthEndpointTests : IntegrationTestsBase private const string RedirectUri = "http://localhost/callback"; private const string MetadataClientId = "https://oauth.example/client.json"; private const string MetadataNoScopeClientId = "https://oauth.example/no-scope-client.json"; + private const string MetadataMissingNameClientId = "https://oauth.example/missing-name-client.json"; private const string MetadataRedirectUri = "https://oauth.example/callback"; private const string ClaudeMetadataClientId = "https://claude.ai/oauth/claude-code-client-metadata"; private const string ClaudeLoopbackRedirectUri = "http://localhost:48272/callback"; @@ -82,6 +83,7 @@ public async Task GetAuthorizationServerMetadataAsync_ReturnsOAuthMetadata() Assert.Contains(AuthorizationRoles.McpRead, metadata.ScopesSupported); Assert.Contains(AuthorizationRoles.StacksWrite, metadata.ScopesSupported); Assert.True(metadata.ClientIdMetadataDocumentSupported); + Assert.True(metadata.AuthorizationResponseIssParameterSupported); } [Fact] @@ -255,7 +257,7 @@ public async Task GetMcpProtectedResourceMetadataAsync_ReturnsMcpResourceMetadat Assert.Contains("http://localhost:7110", metadata.AuthorizationServers); Assert.Contains("header", metadata.BearerMethodsSupported); Assert.Contains(AuthorizationRoles.McpRead, metadata.ScopesSupported); - Assert.Contains(AuthorizationRoles.OfflineAccess, metadata.ScopesSupported); + Assert.DoesNotContain(AuthorizationRoles.OfflineAccess, metadata.ScopesSupported); } [Fact] @@ -289,7 +291,7 @@ public async Task McpAsync_WithoutAuth_ReturnsProtectedResourceChallenge() Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); var challenge = Assert.Single(response.Headers.WwwAuthenticate); Assert.Equal("Bearer", challenge.Scheme); - Assert.Equal("resource_metadata=\"http://localhost:7110/.well-known/oauth-protected-resource/mcp\"", challenge.Parameter); + Assert.Equal("resource_metadata=\"http://localhost:7110/.well-known/oauth-protected-resource/mcp\", scope=\"mcp:read\"", challenge.Parameter); } [Fact] @@ -304,7 +306,37 @@ public async Task McpAsync_GetWithoutAuth_ReturnsProtectedResourceChallenge() Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); var challenge = Assert.Single(response.Headers.WwwAuthenticate); Assert.Equal("Bearer", challenge.Scheme); - Assert.Equal("resource_metadata=\"http://localhost:7110/.well-known/oauth-protected-resource/mcp\"", challenge.Parameter); + Assert.Equal("resource_metadata=\"http://localhost:7110/.well-known/oauth-protected-resource/mcp\", scope=\"mcp:read\"", challenge.Parameter); + } + + [Fact] + public async Task McpAsync_UntrustedOrigin_ReturnsForbidden() + { + using var client = _server.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") + { + Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"server/discover\"}", Encoding.UTF8, "application/json") + }; + request.Headers.Add("Origin", "https://attacker.example"); + + var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task McpAsync_CanonicalOrigin_ContinuesToAuthorization() + { + using var client = _server.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") + { + Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"server/discover\"}", Encoding.UTF8, "application/json") + }; + request.Headers.Add("Origin", "http://localhost:7110"); + + var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] @@ -415,6 +447,7 @@ public async Task CompleteAuthorizeAsync_ValidRequest_ReturnsRedirectUri() Assert.True(query.TryGetValue("code", out var code)); Assert.False(String.IsNullOrEmpty(code.ToString())); Assert.Equal("state-value", query["state"].ToString()); + Assert.Equal("http://localhost:7110", query["iss"].ToString()); } [Fact] @@ -669,6 +702,23 @@ public async Task CompleteAuthorizeAsync_ClientMetadataDocumentMismatch_ReturnsB Assert.Null(application); } + [Fact] + public async Task CompleteAuthorizeAsync_ClientMetadataDocumentMissingName_ReturnsBadRequest() + { + using var client = CreateHttpClient(); + using var request = CreateAuthorizeJsonRequest(PkceVerifier, MetadataRedirectUri, clientId: MetadataMissingNameClientId); + + var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var error = await response.DeserializeAsync(ensureSuccess: false); + Assert.NotNull(error); + Assert.Equal("invalid_client", error.Error); + + var application = await _oauthApplicationRepository.GetByClientIdAsync(MetadataMissingNameClientId, o => o.ImmediateConsistency()); + Assert.Null(application); + } + [Fact] public async Task TokenAsync_ValidAuthorizationCode_ReturnsOAuthTokens() { @@ -859,6 +909,13 @@ public async Task OAuthBearer_McpV2Client_UsesNativeStatelessProtocolAndCallsToo ["projectId"] = TestConstants.ProjectId }, cancellationToken: TestContext.Current.CancellationToken); + var missingProject = await client.CallToolAsync( + "get_project", + new Dictionary + { + ["projectId"] = ObjectId.GenerateNewId().ToString() + }, + cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(nativeProtocolVersion, client.NegotiatedProtocolVersion); Assert.Null(client.SessionId); @@ -872,6 +929,7 @@ public async Task OAuthBearer_McpV2Client_UsesNativeStatelessProtocolAndCallsToo Assert.NotEqual(true, project.IsError); Assert.NotNull(project.StructuredContent); Assert.Contains(TestConstants.ProjectId, project.StructuredContent.ToString(), StringComparison.Ordinal); + Assert.True(missingProject.IsError); } [Theory] @@ -929,6 +987,27 @@ await SendRequestAsync(r => r .StatusCodeShouldBeForbidden()); } + [Fact] + public async Task OAuthBearer_McpResourceMissingRequiredScope_ReturnsInsufficientScopeChallenge() + { + var token = await IssueTokenAsync(); + var storedToken = await GetStoredOAuthTokenAsync(token.AccessToken); + Assert.NotNull(storedToken); + storedToken.Scopes = [AuthorizationRoles.ProjectsRead, AuthorizationRoles.OfflineAccess]; + await _oauthTokenRepository.SaveAsync(storedToken, o => o.ImmediateConsistency()); + + using var client = _server.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "/mcp"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + + var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var challenge = Assert.Single(response.Headers.WwwAuthenticate); + Assert.Equal("Bearer", challenge.Scheme); + Assert.Equal("error=\"insufficient_scope\", scope=\"mcp:read\", resource_metadata=\"http://localhost:7110/.well-known/oauth-protected-resource/mcp\"", challenge.Parameter); + } + [Fact] public async Task OAuthBearer_RestApiResourceUsesSelectedOrganizations() { @@ -1646,6 +1725,15 @@ private sealed class FakeOAuthClientMetadataService : IOAuthClientMetadataServic Scope = String.Join(' ', OAuthService.SupportedScopes), TokenEndpointAuthMethod = "none" }, + MetadataMissingNameClientId => new OAuthClientMetadataDocument + { + ClientId = MetadataMissingNameClientId, + RedirectUris = [MetadataRedirectUri], + GrantTypes = [OAuthGrantTypes.AuthorizationCode], + ResponseTypes = ["code"], + Scope = String.Join(' ', OAuthService.SupportedScopes), + TokenEndpointAuthMethod = "none" + }, ClaudeMetadataClientId => new OAuthClientMetadataDocument { ClientId = ClaudeMetadataClientId, diff --git a/tests/Exceptionless.Tests/Services/OAuthClientMetadataServiceTests.cs b/tests/Exceptionless.Tests/Services/OAuthClientMetadataServiceTests.cs new file mode 100644 index 0000000000..5c331cfd47 --- /dev/null +++ b/tests/Exceptionless.Tests/Services/OAuthClientMetadataServiceTests.cs @@ -0,0 +1,119 @@ +using System.Net; +using System.Net.Http.Headers; +using Exceptionless.Core.Configuration; +using Exceptionless.Core.Services; +using Foundatio.Caching; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Exceptionless.Tests.Services; + +public sealed class OAuthClientMetadataServiceTests +{ + [Theory] + [InlineData("https://oauth.example/client.json", true)] + [InlineData("https://oauth.example/clients/client.json", true)] + [InlineData("https://oauth.example", false)] + [InlineData("https://oauth.example/", false)] + [InlineData("http://oauth.example/client.json", false)] + [InlineData("https://oauth.example/client.json#fragment", false)] + public void TryCreateClientMetadataDocumentUri_WithClientId_ValidatesHttpsPath(string clientId, bool expected) + { + bool isValid = OAuthClientMetadataService.TryCreateClientMetadataDocumentUri(clientId, out _); + + Assert.Equal(expected, isValid); + } + + [Fact] + public async Task GetClientMetadataAsync_NoStoreResponse_DoesNotCacheDocument() + { + var handler = new StubHttpMessageHandler(CreateMetadataResponse); + using var service = CreateService(handler); + + Assert.NotNull(await service.ClientMetadataService.GetClientMetadataAsync("https://oauth.example/client.json")); + Assert.NotNull(await service.ClientMetadataService.GetClientMetadataAsync("https://oauth.example/client.json")); + + Assert.Equal(2, handler.RequestCount); + + static HttpResponseMessage CreateMetadataResponse() + { + var response = CreateSuccessfulMetadataResponse(); + response.Headers.CacheControl = new CacheControlHeaderValue { NoStore = true }; + return response; + } + } + + [Fact] + public async Task GetClientMetadataAsync_MaxAgeResponse_CachesDocument() + { + var handler = new StubHttpMessageHandler(CreateMetadataResponse); + using var service = CreateService(handler); + + Assert.NotNull(await service.ClientMetadataService.GetClientMetadataAsync("https://oauth.example/client.json")); + Assert.NotNull(await service.ClientMetadataService.GetClientMetadataAsync("https://oauth.example/client.json")); + + Assert.Equal(1, handler.RequestCount); + + static HttpResponseMessage CreateMetadataResponse() + { + var response = CreateSuccessfulMetadataResponse(); + response.Headers.CacheControl = new CacheControlHeaderValue { MaxAge = TimeSpan.FromMinutes(10) }; + return response; + } + } + + private static OAuthClientMetadataServiceFixture CreateService(HttpMessageHandler handler) + { + var cache = new InMemoryCacheClient(new InMemoryCacheClientOptions + { + LoggerFactory = NullLoggerFactory.Instance, + TimeProvider = TimeProvider.System + }); + var httpClient = new HttpClient(handler); + var service = new OAuthClientMetadataService( + httpClient, + new OAuthServerOptions(), + cache, + NullLogger.Instance, + TimeProvider.System); + + return new OAuthClientMetadataServiceFixture(service, httpClient, cache); + } + + private static HttpResponseMessage CreateSuccessfulMetadataResponse() + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(""" + { + "client_id": "https://oauth.example/client.json", + "client_name": "Example Client", + "redirect_uris": ["https://oauth.example/callback"] + } + """) + }; + } + + private sealed class StubHttpMessageHandler(Func responseFactory) : HttpMessageHandler + { + public int RequestCount { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestCount++; + return Task.FromResult(responseFactory()); + } + } + + private sealed record OAuthClientMetadataServiceFixture( + OAuthClientMetadataService ClientMetadataService, + HttpClient HttpClient, + InMemoryCacheClient Cache) : IDisposable + { + public void Dispose() + { + HttpClient.Dispose(); + Cache.Dispose(); + } + } +}