Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions src/Exceptionless.Core/Services/OAuthClientMetadataService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public interface IOAuthClientMetadataService
Task<OAuthClientMetadataDocument?> GetClientMetadataAsync(string clientId);
}

public sealed class OAuthClientMetadataService(HttpClient httpClient, OAuthServerOptions options, ICacheClient cacheClient, ILogger<OAuthClientMetadataService> logger) : IOAuthClientMetadataService
public sealed class OAuthClientMetadataService(HttpClient httpClient, OAuthServerOptions options, ICacheClient cacheClient, ILogger<OAuthClientMetadataService> logger, TimeProvider timeProvider) : IOAuthClientMetadataService
{
private const string CachePrefix = "oauth:cimd:";
private const string FailureCachePrefix = "oauth:cimd-failure:";
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor s-maxage in the shared metadata cache

When a metadata server returns a shared-cache directive such as Cache-Control: s-maxage=0, max-age=3600, this ICacheClient cache uses max-age and retains the document for an hour even though s-maxage forbids shared reuse; if only s-maxage is present, it instead falls back to the configured lifetime. Because production can back this cache with Redis, stale redirect URIs and scopes can be reused across requests and instances beyond the client's requested freshness window; prefer SharedMaxAge when it is present.

Useful? React with 👍 / 👎.

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<MemoryStream> ReadLimitedAsync(Stream stream, int maxBytes, CancellationToken cancellationToken)
{
byte[] buffer = new byte[8192];
Expand Down
20 changes: 9 additions & 11 deletions src/Exceptionless.Core/Services/OAuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,10 @@ public class OAuthService(OAuthServerOptions options, ICacheClient cacheClient,
AuthorizationRoles.ProjectsRead,
AuthorizationRoles.StacksRead,
AuthorizationRoles.StacksWrite,
AuthorizationRoles.EventsRead,
AuthorizationRoles.OfflineAccess
AuthorizationRoles.EventsRead

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep offline_access discoverable for existing MCP clients

BLOCKER: Existing clients that construct authorization requests from the MCP protected-resource metadata will stop requesting offline_access after it disappears from scopes_supported; their newly issued tokens then lack refresh tokens and require reauthorization after the access token expires. This changes a published API response, so preserve the scope or obtain explicit approval and provide a compatible migration path.

AGENTS.md reference: AGENTS.md:L67-L67

Useful? React with 👍 / 👎.

],
[
AuthorizationRoles.McpRead,
AuthorizationRoles.OfflineAccess
AuthorizationRoles.McpRead
]);

public static readonly OAuthResourceDefinition RestApiResource = new("/api/v2",
Expand Down Expand Up @@ -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;

Expand All @@ -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()
Expand Down
8 changes: 5 additions & 3 deletions src/Exceptionless.Web/Api/Handlers/OAuthHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ public Task<IResult> Handle(GetAuthorizationServerMetadata message)
TokenEndpointAuthMethodsSupported = ["none"],
ScopesSupported = OAuthService.SupportedScopes,
ResourceDocumentation = $"{origin}/mcp",
ClientIdMetadataDocumentSupported = oauthService.ClientIdMetadataDocumentSupported
ClientIdMetadataDocumentSupported = oauthService.ClientIdMetadataDocumentSupported,
AuthorizationResponseIssParameterSupported = true
}));
}

Expand Down Expand Up @@ -193,7 +194,7 @@ private async Task<IResult> 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 });
}

Expand All @@ -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;

Expand Down
1 change: 1 addition & 0 deletions src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions src/Exceptionless.Web/Mcp/McpOriginValidationMiddleware.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
3 changes: 3 additions & 0 deletions src/Exceptionless.Web/Models/OAuth/OAuthModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/Exceptionless.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ ApplicationException applicationException when applicationException.Message.Cont
app.UseDefaultFiles();
app.UseFileServer();
app.UseRouting();
app.UseMiddleware<McpOriginValidationMiddleware>();
app.UseCors("AllowAny");
app.UseHttpMethodOverride();
app.UseForwardedHeaders();
Expand Down
33 changes: 32 additions & 1 deletion src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthenticateResult> AuthenticateOAuthBearerAsync(string token)
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion tests/Exceptionless.Tests/Api/Data/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -12017,6 +12018,9 @@
},
"client_id_metadata_document_supported": {
"type": "boolean"
},
"authorization_response_iss_parameter_supported": {
"type": "boolean"
}
}
},
Expand Down
Loading
Loading