From a2a0d9213676680ede0b73cc15b1f7c0640f2de3 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 27 Jun 2026 12:37:52 -0500 Subject: [PATCH 01/23] Add REST OAuth resource scopes --- .../Authorization/AuthorizationRoles.cs | 4 + .../Extensions/IdentityUtils.cs | 6 +- src/Exceptionless.Core/Models/Token.cs | 25 ++ .../Services/OAuthService.cs | 96 ++++++-- .../(auth)/oauth/authorize/+page.svelte | 82 +++++-- .../Controllers/EventController.cs | 42 ++-- .../Controllers/OAuthController.cs | 79 ++++--- .../Controllers/ProjectController.cs | 10 +- .../Controllers/StackController.cs | 25 +- .../Models/OAuth/OAuthModels.cs | 6 +- .../Security/ApiKeyAuthenticationHandler.cs | 46 ++-- src/Exceptionless.Web/Startup.cs | 4 + .../Controllers/Data/controller-manifest.json | 143 ++++-------- .../Controllers/Data/openapi.json | 30 ++- .../Controllers/ExceptionlessMcpToolsTests.cs | 1 + .../Controllers/OAuthControllerTests.cs | 218 +++++++++++++----- .../Serializer/Models/TokenSerializerTests.cs | 35 +++ tests/http/oauth.http | 32 ++- 18 files changed, 612 insertions(+), 272 deletions(-) diff --git a/src/Exceptionless.Core/Authorization/AuthorizationRoles.cs b/src/Exceptionless.Core/Authorization/AuthorizationRoles.cs index 2d950468f..546b06e86 100644 --- a/src/Exceptionless.Core/Authorization/AuthorizationRoles.cs +++ b/src/Exceptionless.Core/Authorization/AuthorizationRoles.cs @@ -9,6 +9,10 @@ public static class AuthorizationRoles public const string GlobalAdminPolicy = nameof(GlobalAdminPolicy); public const string GlobalAdmin = "global"; public const string McpPolicy = nameof(McpPolicy); + public const string ProjectsReadPolicy = nameof(ProjectsReadPolicy); + public const string StacksReadPolicy = nameof(StacksReadPolicy); + public const string StacksWritePolicy = nameof(StacksWritePolicy); + public const string EventsReadPolicy = nameof(EventsReadPolicy); public const string McpRead = "mcp:read"; public const string ProjectsRead = "projects:read"; public const string StacksRead = "stacks:read"; diff --git a/src/Exceptionless.Core/Extensions/IdentityUtils.cs b/src/Exceptionless.Core/Extensions/IdentityUtils.cs index 3b76d0dec..3faec63f4 100644 --- a/src/Exceptionless.Core/Extensions/IdentityUtils.cs +++ b/src/Exceptionless.Core/Extensions/IdentityUtils.cs @@ -50,10 +50,14 @@ public static ClaimsIdentity ToIdentity(this Token token) public static ClaimsIdentity ToIdentity(this User user, Token? token = null) { + var organizationIds = token is { OAuthType: OAuthTokenType.Access } + ? token.OAuthOrganizationIds + : user.OrganizationIds; + var claims = new List(7 + user.Roles.Count) { new(ClaimTypes.Name, user.EmailAddress), new(ClaimTypes.NameIdentifier, user.Id), - new(OrganizationIdsClaim, String.Join(",", user.OrganizationIds)) + new(OrganizationIdsClaim, String.Join(",", organizationIds)) }; if (token is not null) diff --git a/src/Exceptionless.Core/Models/Token.cs b/src/Exceptionless.Core/Models/Token.cs index 5e19480b2..eeead1114 100644 --- a/src/Exceptionless.Core/Models/Token.cs +++ b/src/Exceptionless.Core/Models/Token.cs @@ -40,6 +40,7 @@ public class Token : IOwnedByOrganizationAndProjectWithIdentity, IHaveDates, IVa public string? OAuthClientId { get; set; } public string? OAuthResource { get; set; } public DateTime? OAuthRefreshExpiresUtc { get; set; } + public HashSet OAuthOrganizationIds { get; set; } = new(); public HashSet Scopes { get; set; } = new(); public DateTime? ExpiresUtc { get; set; } public string? Notes { get; set; } @@ -83,6 +84,30 @@ public IEnumerable Validate(ValidationContext validationContex { yield return new ValidationResult("Only access tokens can be disabled", [nameof(IsDisabled)]); } + + if (OAuthType == OAuthTokenType.Access) + { + if (Type != TokenType.Access) + yield return new ValidationResult("OAuth tokens must be access tokens.", [nameof(Type)]); + + if (String.IsNullOrEmpty(UserId)) + yield return new ValidationResult("OAuth tokens must be associated with a user.", [nameof(UserId)]); + + if (String.IsNullOrWhiteSpace(OAuthClientId)) + yield return new ValidationResult("OAuth tokens must specify a client id.", [nameof(OAuthClientId)]); + + if (String.IsNullOrWhiteSpace(OAuthResource)) + yield return new ValidationResult("OAuth tokens must specify a resource.", [nameof(OAuthResource)]); + + if (Scopes.Count == 0) + yield return new ValidationResult("OAuth tokens must specify at least one scope.", [nameof(Scopes)]); + + if (OAuthOrganizationIds.Count == 0) + yield return new ValidationResult("OAuth tokens must specify at least one organization id.", [nameof(OAuthOrganizationIds)]); + + foreach (string _ in OAuthOrganizationIds.Where(String.IsNullOrWhiteSpace)) + yield return new ValidationResult("OAuth organization id cannot be empty.", [nameof(OAuthOrganizationIds)]); + } } } diff --git a/src/Exceptionless.Core/Services/OAuthService.cs b/src/Exceptionless.Core/Services/OAuthService.cs index 81a4aa0aa..d58c594be 100644 --- a/src/Exceptionless.Core/Services/OAuthService.cs +++ b/src/Exceptionless.Core/Services/OAuthService.cs @@ -21,22 +21,40 @@ public class OAuthService(OAuthServerOptions options, ICacheClient cacheClient, public const int PkceCodeChallengeLength = 43; public const int PkceCodeVerifierMinLength = 43; public const int PkceCodeVerifierMaxLength = 128; - public static readonly IReadOnlyCollection SupportedScopes = + public static readonly OAuthResourceDefinition McpResource = new("/mcp", [ AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead, AuthorizationRoles.StacksRead, AuthorizationRoles.StacksWrite, - AuthorizationRoles.EventsRead, - AuthorizationRoles.OfflineAccess + AuthorizationRoles.EventsRead + ], + [ + AuthorizationRoles.McpRead + ]); + + public static readonly OAuthResourceDefinition RestApiResource = new("/api/v2", + [ + AuthorizationRoles.ProjectsRead, + AuthorizationRoles.StacksRead, + AuthorizationRoles.StacksWrite, + AuthorizationRoles.EventsRead + ], []); + + public static readonly IReadOnlyCollection ProtectedResources = + [ + McpResource, + RestApiResource ]; - public static readonly IReadOnlyCollection DefaultScopes = + public static readonly IReadOnlyCollection SupportedScopes = [ AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead, AuthorizationRoles.StacksRead, - AuthorizationRoles.EventsRead + AuthorizationRoles.StacksWrite, + AuthorizationRoles.EventsRead, + AuthorizationRoles.OfflineAccess ]; private const string AuthorizationCodeCachePrefix = "oauth:code:"; @@ -77,7 +95,7 @@ public async Task RegisterClientAsync(OAuthClient var scopes = NormalizeScopes(request.Scope); if (scopes.Count == 0) - scopes = DefaultScopes; + return OAuthClientRegistrationResult.Invalid("invalid_client_metadata", "At least one scope is required."); if (scopes.Any(s => !SupportedScopes.Contains(s, StringComparer.Ordinal))) return OAuthClientRegistrationResult.Invalid("invalid_client_metadata", "One or more scopes are not supported."); @@ -201,9 +219,10 @@ private bool TryCreateObservedApplication(string clientId, OAuthClientMetadataDo return false; var metadataScopes = NormalizeScopes(metadata.Scope); - var scopes = metadataScopes.Count > 0 - ? metadataScopes.Where(s => SupportedScopes.Contains(s, StringComparer.Ordinal)).Distinct(StringComparer.Ordinal).ToArray() - : DefaultScopes.ToArray(); + if (metadataScopes.Count == 0) + return false; + + var scopes = metadataScopes.Where(s => SupportedScopes.Contains(s, StringComparer.Ordinal)).Distinct(StringComparer.Ordinal).ToArray(); if (scopes.Length == 0) return false; @@ -261,7 +280,7 @@ private async Task CreateUniqueClientIdAsync() public IReadOnlyCollection GetAllowedScopes(OAuthClientOptions client) { - return client.Scopes.Count > 0 ? client.Scopes : DefaultScopes; + return client.Scopes; } public IReadOnlyCollection NormalizeScopes(string? scopes) @@ -275,7 +294,7 @@ public IReadOnlyCollection NormalizeScopes(string? scopes) .ToArray(); } - public async Task ValidateAuthorizationRequestAsync(OAuthAuthorizeRequest request, string expectedResource) + public async Task ValidateAuthorizationRequestAsync(OAuthAuthorizeRequest request, string expectedResource, OAuthResourceDefinition resourceDefinition) { if (String.IsNullOrWhiteSpace(request.ClientId)) return OAuthValidationResult.Invalid("invalid_request", "Missing client_id."); @@ -298,16 +317,26 @@ public async Task ValidateAuthorizationRequestAsync(OAuth var requestedScopes = NormalizeScopes(request.Scope); if (requestedScopes.Count == 0) - requestedScopes = DefaultScopes; + return OAuthValidationResult.Invalid("invalid_scope", "At least one scope is required."); var allowedScopes = GetAllowedScopes(client); if (requestedScopes.Any(s => !allowedScopes.Contains(s, StringComparer.Ordinal))) return OAuthValidationResult.Invalid("invalid_scope", "One or more scopes are not allowed for this client."); + if (resourceDefinition.RequiredScopes.Any(s => !requestedScopes.Contains(s, StringComparer.Ordinal))) + return OAuthValidationResult.Invalid("invalid_scope", "One or more required resource scopes are missing."); + + var requestedResourceScopes = requestedScopes.Where(s => !String.Equals(s, AuthorizationRoles.OfflineAccess, StringComparison.Ordinal)).ToArray(); + if (requestedResourceScopes.Length == 0) + return OAuthValidationResult.Invalid("invalid_scope", "At least one resource scope is required."); + + if (requestedResourceScopes.Any(s => !resourceDefinition.Scopes.Contains(s, StringComparer.Ordinal))) + return OAuthValidationResult.Invalid("invalid_scope", "One or more scopes are not supported by the requested resource."); + return OAuthValidationResult.Valid(client, requestedScopes); } - public async Task CreateAuthorizationCodeAsync(OAuthAuthorizeRequest request, string userId) + public async Task CreateAuthorizationCodeAsync(OAuthAuthorizeRequest request, string userId, IReadOnlyCollection organizationIds) { string code = StringExtensions.GetNewToken(); var authorizationCode = new OAuthAuthorizationCode @@ -318,12 +347,9 @@ public async Task CreateAuthorizationCodeAsync(OAuthAuthorizeRequest req CodeChallenge = request.CodeChallenge, Resource = request.Resource ?? throw new InvalidOperationException("OAuth resource must be validated before creating an authorization code."), Scopes = NormalizeScopes(request.Scope), + OrganizationIds = organizationIds, CreatedUtc = timeProvider.GetUtcNow().UtcDateTime }; - - if (authorizationCode.Scopes.Count == 0) - authorizationCode.Scopes = DefaultScopes; - await cacheClient.SetAsync(GetAuthorizationCodeCacheKey(code), authorizationCode, options.AuthorizationCodeLifetime); return code; } @@ -355,7 +381,7 @@ public async Task ExchangeAuthorizationCodeAsync(OAuthTok if (!ValidateCodeVerifier(code.CodeChallenge, request.CodeVerifier)) return OAuthTokenIssueResult.Invalid("invalid_grant", "Invalid PKCE verifier."); - return OAuthTokenIssueResult.Success(await CreateTokenAsync(code.UserId, code.ClientId, code.Resource, code.Scopes)); + return OAuthTokenIssueResult.Success(await CreateTokenAsync(code.UserId, code.ClientId, code.Resource, code.Scopes, code.OrganizationIds)); } public async Task RefreshAsync(OAuthTokenRequest request) @@ -375,6 +401,9 @@ public async Task RefreshAsync(OAuthTokenRequest request) if (token is null || token.IsDisabled || token.IsSuspended || token.OAuthType != OAuthTokenType.Access || !String.Equals(token.OAuthClientId, request.ClientId, StringComparison.Ordinal)) return OAuthTokenIssueResult.Invalid("invalid_grant", "Refresh token is invalid."); + if (token.OAuthOrganizationIds.Count == 0) + return OAuthTokenIssueResult.Invalid("invalid_grant", "Refresh token is invalid."); + if (token.OAuthRefreshExpiresUtc.HasValue && token.OAuthRefreshExpiresUtc.Value < timeProvider.GetUtcNow().UtcDateTime) return OAuthTokenIssueResult.Invalid("invalid_grant", "Refresh token is expired."); @@ -382,7 +411,7 @@ public async Task RefreshAsync(OAuthTokenRequest request) token.Refresh = null; await tokenRepository.SaveAsync(token, o => o.ImmediateConsistency()); - return OAuthTokenIssueResult.Success(await CreateTokenAsync(token.UserId!, token.OAuthClientId!, token.OAuthResource!, token.Scopes)); + return OAuthTokenIssueResult.Success(await CreateTokenAsync(token.UserId!, token.OAuthClientId!, token.OAuthResource!, token.Scopes, token.OAuthOrganizationIds)); } public async Task RevokeAsync(string? tokenValue) @@ -406,7 +435,7 @@ public async Task RevokeAsync(string? tokenValue) return true; } - private async Task CreateTokenAsync(string userId, string clientId, string resource, IReadOnlyCollection scopes) + private async Task CreateTokenAsync(string userId, string clientId, string resource, IReadOnlyCollection scopes, IReadOnlyCollection organizationIds) { var utcNow = timeProvider.GetUtcNow().UtcDateTime; var accessToken = StringExtensions.GetNewToken(); @@ -421,6 +450,7 @@ private async Task CreateTokenAsync(string userId, string cl OAuthResource = resource, Refresh = refreshToken, Scopes = scopes.ToHashSet(StringComparer.Ordinal), + OAuthOrganizationIds = organizationIds.ToHashSet(StringComparer.Ordinal), CreatedUtc = utcNow, UpdatedUtc = utcNow, ExpiresUtc = utcNow.Add(options.AccessTokenLifetime), @@ -458,7 +488,27 @@ private static bool IsValidCodeVerifier(string? verifier) && CodeVerifierRegex.IsMatch(verifier); } - private static bool IsExpectedResource(string? resource, string expectedResource) + public static string CreateResourceUri(string origin, OAuthResourceDefinition resourceDefinition) + { + return $"{origin.TrimEnd('/')}{resourceDefinition.Path}"; + } + + public static bool TryGetProtectedResource(string? resource, string origin, out OAuthResourceDefinition resourceDefinition) + { + foreach (var candidate in ProtectedResources) + { + if (IsExpectedResource(resource, CreateResourceUri(origin, candidate))) + { + resourceDefinition = candidate; + return true; + } + } + + resourceDefinition = null!; + return false; + } + + public static bool IsExpectedResource(string? resource, string expectedResource) { if (String.IsNullOrWhiteSpace(resource) || !Uri.TryCreate(resource, UriKind.Absolute, out var resourceUri) || !Uri.TryCreate(expectedResource, UriKind.Absolute, out var expectedResourceUri)) return false; @@ -547,6 +597,7 @@ public record OAuthAuthorizeRequest public required string CodeChallenge { get; init; } public required string CodeChallengeMethod { get; init; } public string? Resource { get; init; } + public IReadOnlyCollection OrganizationIds { get; init; } = []; } public record OAuthTokenRequest @@ -623,8 +674,11 @@ public record OAuthAuthorizationCode public required string Resource { get; init; } public IReadOnlyCollection Scopes { get; set; } = []; public DateTime CreatedUtc { get; init; } + public IReadOnlyCollection OrganizationIds { get; init; } = []; } + +public sealed record OAuthResourceDefinition(string Path, IReadOnlyCollection Scopes, IReadOnlyCollection RequiredScopes); public record OAuthValidationResult(bool IsValid, OAuthClientOptions? Client, IReadOnlyCollection Scopes, string? Error, string? ErrorDescription) { public static OAuthValidationResult Valid(OAuthClientOptions client, IReadOnlyCollection scopes) => new(true, client, scopes, null, null); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte index 4a16a64d9..ef79d1d29 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte @@ -9,11 +9,13 @@ import { Badge } from '$comp/ui/badge'; import { Button } from '$comp/ui/button'; import * as Card from '$comp/ui/card'; + import { Checkbox } from '$comp/ui/checkbox'; import { Spinner } from '$comp/ui/spinner'; import { accessToken } from '$features/auth/index.svelte'; import { getOrganizationsQuery } from '$features/organizations/api.svelte'; import { getMeQuery } from '$features/users/api.svelte'; import { useFetchClient } from '@exceptionless/fetchclient'; + import { SvelteSet } from 'svelte/reactivity'; interface OAuthAuthorizeResponse { error?: string; @@ -23,6 +25,7 @@ let errorMessage = $state(null); let isAuthorizing = $state(false); + const selectedOrganizationIds = new SvelteSet(); const meQuery = getMeQuery(); const organizationsQuery = getOrganizationsQuery({ params: { mode: null } }); @@ -32,10 +35,8 @@ const resource = $derived(page.url.searchParams.get('resource') ?? 'Unknown resource'); const accountDisplayName = $derived(meQuery.data?.full_name || meQuery.data?.email_address || 'Unknown account'); const organizations = $derived(organizationsQuery.data?.data ?? []); - const visibleOrganizations = $derived(organizations.slice(0, 6)); - const hiddenOrganizationCount = $derived(Math.max(organizations.length - visibleOrganizations.length, 0)); - const defaultRequestedScopes = ['mcp:read', 'projects:read', 'stacks:read', 'events:read']; const requestedScopes = $derived(getRequestedScopes()); + const hasSelectedOrganizations = $derived(selectedOrganizationIds.size > 0); $effect(() => { if (!browser || accessToken.current) { @@ -47,11 +48,37 @@ void goto(loginUrl, { replaceState: true }); }); + $effect(() => { + const organizationIds = organizations.map((organization) => organization.id).filter((id): id is string => Boolean(id)); + if (organizationIds.length === 0) { + if (selectedOrganizationIds.size > 0) { + selectedOrganizationIds.clear(); + } + + return; + } + + const validSelectedOrganizationIds = [...selectedOrganizationIds].filter((id) => organizationIds.includes(id)); + if (validSelectedOrganizationIds.length === selectedOrganizationIds.size && selectedOrganizationIds.size > 0) { + return; + } + + selectedOrganizationIds.clear(); + for (const organizationId of validSelectedOrganizationIds.length > 0 ? validSelectedOrganizationIds : organizationIds) { + selectedOrganizationIds.add(organizationId); + } + }); + async function approveAuthorization(): Promise { if (isAuthorizing) { return; } + if (!hasSelectedOrganizations) { + errorMessage = 'Select at least one organization.'; + return; + } + isAuthorizing = true; errorMessage = null; const client = useFetchClient(); @@ -61,6 +88,7 @@ client_id: page.url.searchParams.get('client_id'), code_challenge: page.url.searchParams.get('code_challenge'), code_challenge_method: page.url.searchParams.get('code_challenge_method'), + organization_ids: [...selectedOrganizationIds], redirect_uri: page.url.searchParams.get('redirect_uri'), resource: page.url.searchParams.get('resource'), response_type: page.url.searchParams.get('response_type'), @@ -100,7 +128,19 @@ .map((scope) => scope.trim()) .filter(Boolean) ?? []; - return scopes.length > 0 ? scopes : defaultRequestedScopes; + return scopes; + } + + function toggleOrganization(organizationId: string | undefined, checked: 'indeterminate' | boolean): void { + if (!organizationId) { + return; + } + + if (checked === true) { + selectedOrganizationIds.add(organizationId); + } else { + selectedOrganizationIds.delete(organizationId); + } } function cancelAuthorization() { @@ -128,21 +168,23 @@

{meQuery.data?.email_address}

{/if} -
+
Organizations {#if organizationsQuery.isLoading}

Loading organizations...

{:else if organizationsQuery.isError}

Unable to load organizations.

{:else if organizations.length > 0} -

Access applies to these organizations.

-
- {#each visibleOrganizations as organization (organization.id)} - {organization.name} +
+ {#each organizations as organization (organization.id)} + {/each} - {#if hiddenOrganizationCount > 0} - +{hiddenOrganizationCount} more - {/if}
{:else}

This account is not a member of any organizations.

@@ -166,9 +208,13 @@
Scopes
- {#each requestedScopes as scope (scope)} - {scope} - {/each} + {#if requestedScopes.length > 0} + {#each requestedScopes as scope (scope)} + {scope} + {/each} + {:else} +

No scopes requested.

+ {/if}
@@ -179,7 +225,11 @@ - + + + {/each} + + +
+ {/if} +
+ + + + + Revoke Application Access + + Revoke access for "{selectedGrant?.application_name}"? The application will need to complete OAuth again before it can access your account. + + + + Cancel + void revokeSelectedGrant()}> + Revoke Access + + + + diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/routes.svelte.ts index e252488de..a2438359c 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/routes.svelte.ts @@ -37,6 +37,12 @@ export function routes(): NavigationItem[] { icon: Bot, title: 'AI Tools' }, + { + group: 'My Account', + href: resolve('/(app)/account/applications'), + icon: ExternalLogin, + title: 'Applications' + }, { group: 'My Account', href: resolve('/(app)/account/security'), diff --git a/src/Exceptionless.Web/Controllers/UserController.cs b/src/Exceptionless.Web/Controllers/UserController.cs index 9721e8346..5afc5888b 100644 --- a/src/Exceptionless.Web/Controllers/UserController.cs +++ b/src/Exceptionless.Web/Controllers/UserController.cs @@ -9,6 +9,7 @@ using Exceptionless.Web.Extensions; using Exceptionless.Web.Mapping; using Exceptionless.Web.Models; +using Exceptionless.Web.Models.OAuth; using Exceptionless.Web.Utility; using Exceptionless.Web.Utility.OpenApi; using Foundatio.Caching; @@ -26,6 +27,7 @@ public class UserController : RepositoryApiController> GetCurrentUserAsync() return Ok(MapToViewCurrentUser(currentUser)); } + /// + /// Get current user OAuth grants + /// + [HttpGet("me/oauth-grants")] + public async Task>> GetOAuthGrantsAsync() + { + var results = await _tokenRepository.GetOAuthAccessTokensByUserIdAsync(CurrentUser.Id, o => o.PageLimit(MAXIMUM_SKIP)); + var tokens = results.Documents.Where(IsActiveOAuthGrantToken).ToArray(); + if (tokens.Length == 0) + return Ok(Array.Empty()); + + var applicationsByClientId = new Dictionary(StringComparer.Ordinal); + foreach (string clientId in tokens.Select(t => t.OAuthClientId!).Distinct(StringComparer.Ordinal)) + applicationsByClientId[clientId] = await _oauthApplicationRepository.GetByClientIdAsync(clientId); + + var grants = tokens + .GroupBy(t => t.OAuthClientId!, StringComparer.Ordinal) + .Select(group => MapToOAuthGrant(group.ToArray(), applicationsByClientId[group.Key])) + .OrderBy(g => g.ApplicationName, StringComparer.OrdinalIgnoreCase) + .ThenBy(g => g.ClientId, StringComparer.Ordinal) + .ToArray(); + + return Ok(grants); + } + + /// + /// Revoke current user OAuth grant + /// + /// The representative OAuth access token id. + /// The OAuth grant could not be found. + [HttpDelete("me/oauth-grants/{id:minlength(1)}")] + public async Task RevokeOAuthGrantAsync(string id) + { + var token = await _tokenRepository.GetByIdAsync(id, o => o.ImmediateConsistency()); + if (token is null || token.OAuthType != OAuthTokenType.Access || !String.Equals(token.UserId, CurrentUser.Id, StringComparison.Ordinal) || String.IsNullOrWhiteSpace(token.OAuthClientId)) + return NotFound(); + + var results = await _tokenRepository.GetOAuthAccessTokensByUserIdAndClientIdAsync(CurrentUser.Id, token.OAuthClientId, o => o.ImmediateConsistency().PageLimit(MAXIMUM_SKIP)); + var utcNow = _timeProvider.GetUtcNow().UtcDateTime; + foreach (var oauthToken in results.Documents.Where(t => t.OAuthType == OAuthTokenType.Access)) + { + oauthToken.IsDisabled = true; + oauthToken.Refresh = null; + oauthToken.UpdatedUtc = utcNow; + await _tokenRepository.SaveAsync(oauthToken, o => o.ImmediateConsistency()); + } + + return NoContent(); + } + /// /// Get by id /// @@ -483,6 +536,59 @@ protected override async Task> DeleteModelsAsync(ICollection private ViewCurrentUser MapToViewCurrentUser(User user) => new(user, _intercomOptions) { AvatarUrl = GetUserAvatarUrl(user.Id, user.AvatarFileName) }; + private bool IsActiveOAuthGrantToken(Token token) + { + if (token is { OAuthType: not OAuthTokenType.Access } || token.IsDisabled || token.IsSuspended || String.IsNullOrWhiteSpace(token.OAuthClientId)) + return false; + + var utcNow = _timeProvider.GetUtcNow().UtcDateTime; + bool hasActiveAccessToken = !token.ExpiresUtc.HasValue || token.ExpiresUtc.Value >= utcNow; + bool hasActiveRefreshToken = !String.IsNullOrEmpty(token.Refresh) && (!token.OAuthRefreshExpiresUtc.HasValue || token.OAuthRefreshExpiresUtc.Value >= utcNow); + return hasActiveAccessToken || hasActiveRefreshToken; + } + + private static ViewOAuthGrant MapToOAuthGrant(IReadOnlyCollection tokens, OAuthApplication? application) + { + var latestToken = tokens + .OrderByDescending(t => t.UpdatedUtc) + .ThenByDescending(t => t.CreatedUtc) + .First(); + + return new ViewOAuthGrant + { + Id = latestToken.Id, + ClientId = latestToken.OAuthClientId!, + ApplicationName = application?.Name ?? latestToken.OAuthClientId!, + IsApplicationDisabled = application?.IsDisabled ?? false, + Scopes = tokens + .SelectMany(t => t.Scopes) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(), + OrganizationIds = tokens + .SelectMany(t => t.OAuthOrganizationIds) + .Where(id => !String.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(), + Resources = tokens + .Where(t => !String.IsNullOrWhiteSpace(t.OAuthResource)) + .GroupBy(t => t.OAuthResource!, StringComparer.Ordinal) + .Select(group => new ViewOAuthGrantResource + { + Resource = group.Key, + Scopes = group.SelectMany(t => t.Scopes).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray(), + OrganizationIds = group.SelectMany(t => t.OAuthOrganizationIds).Where(id => !String.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray() + }) + .OrderBy(r => r.Resource, StringComparer.Ordinal) + .ToArray(), + CreatedUtc = tokens.Min(t => t.CreatedUtc), + UpdatedUtc = tokens.Max(t => t.UpdatedUtc), + ExpiresUtc = tokens.Select(t => t.ExpiresUtc).Max(), + RefreshExpiresUtc = tokens.Select(t => t.OAuthRefreshExpiresUtc).Max() + }; + } + private string? GetUserAvatarUrl(string id, string? fileName) { if (String.IsNullOrWhiteSpace(fileName)) diff --git a/src/Exceptionless.Web/Models/OAuth/ViewOAuthGrant.cs b/src/Exceptionless.Web/Models/OAuth/ViewOAuthGrant.cs new file mode 100644 index 000000000..81b01719b --- /dev/null +++ b/src/Exceptionless.Web/Models/OAuth/ViewOAuthGrant.cs @@ -0,0 +1,23 @@ +namespace Exceptionless.Web.Models.OAuth; + +public record ViewOAuthGrant +{ + public required string Id { get; init; } + public required string ClientId { get; init; } + public required string ApplicationName { get; init; } + public bool IsApplicationDisabled { get; init; } + public required IReadOnlyCollection Scopes { get; init; } + public required IReadOnlyCollection OrganizationIds { get; init; } + public required IReadOnlyCollection Resources { get; init; } + public DateTime CreatedUtc { get; init; } + public DateTime UpdatedUtc { get; init; } + public DateTime? ExpiresUtc { get; init; } + public DateTime? RefreshExpiresUtc { get; init; } +} + +public record ViewOAuthGrantResource +{ + public required string Resource { get; init; } + public required IReadOnlyCollection Scopes { get; init; } + public required IReadOnlyCollection OrganizationIds { get; init; } +} diff --git a/tests/Exceptionless.Tests/Controllers/Data/controller-manifest.json b/tests/Exceptionless.Tests/Controllers/Data/controller-manifest.json index b0e4fd872..6553ab629 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/controller-manifest.json +++ b/tests/Exceptionless.Tests/Controllers/Data/controller-manifest.json @@ -2841,6 +2841,36 @@ ], "ExcludeFromDescription": false }, + { + "Controller": "UserController", + "Action": "GetOAuthGrantsAsync", + "HttpMethod": "GET", + "Route": "/api/v2/users/me/oauth-grants", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "UserController", + "Action": "RevokeOAuthGrantAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/users/me/oauth-grants/{id:minlength(1)}", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, { "Controller": "UserController", "Action": "UnverifyEmailAddressAsync", diff --git a/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index bd27b947e..ce957a6fa 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -7880,6 +7880,60 @@ } } }, + "/api/v2/users/me/oauth-grants": { + "get": { + "tags": [ + "User" + ], + "summary": "Get current user OAuth grants", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ViewOAuthGrant" + } + } + } + } + } + } + } + }, + "/api/v2/users/me/oauth-grants/{id}": { + "delete": { + "tags": [ + "User" + ], + "summary": "Revoke current user OAuth grant", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The representative OAuth access token id.", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { } + } + }, + "404": { + "description": "The OAuth grant could not be found." + } + } + } + }, "/api/v2/users/{id}": { "get": { "tags": [ @@ -10253,6 +10307,99 @@ } } }, + "ViewOAuthGrant": { + "required": [ + "id", + "client_id", + "application_name", + "scopes", + "organization_ids", + "resources", + "is_application_disabled", + "created_utc", + "updated_utc" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "client_id": { + "type": "string" + }, + "application_name": { + "type": "string" + }, + "is_application_disabled": { + "type": "boolean" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "organization_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "resources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ViewOAuthGrantResource" + } + }, + "created_utc": { + "type": "string", + "format": "date-time" + }, + "updated_utc": { + "type": "string", + "format": "date-time" + }, + "expires_utc": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "refresh_expires_utc": { + "type": [ + "null", + "string" + ], + "format": "date-time" + } + } + }, + "ViewOAuthGrantResource": { + "required": [ + "resource", + "scopes", + "organization_ids" + ], + "type": "object", + "properties": { + "resource": { + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "organization_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "ViewOrganization": { "required": [ "id", diff --git a/tests/Exceptionless.Tests/Controllers/UserControllerTests.cs b/tests/Exceptionless.Tests/Controllers/UserControllerTests.cs index d5fe005bc..de9934931 100644 --- a/tests/Exceptionless.Tests/Controllers/UserControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/UserControllerTests.cs @@ -1,13 +1,16 @@ using Exceptionless.Core.Authorization; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; using Exceptionless.Core.Utility; using Exceptionless.Tests.Extensions; using Exceptionless.Web.Controllers; using Exceptionless.Web.Models; +using Exceptionless.Web.Models.OAuth; using Exceptionless.Web.Utility; using FluentRest; using Foundatio.Repositories; +using Foundatio.Repositories.Utility; using Xunit; namespace Exceptionless.Tests.Controllers; @@ -15,10 +18,14 @@ namespace Exceptionless.Tests.Controllers; public sealed class UserControllerTests : IntegrationTestsBase { private readonly IUserRepository _userRepository; + private readonly IOAuthApplicationRepository _oauthApplicationRepository; + private readonly ITokenRepository _tokenRepository; public UserControllerTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { _userRepository = GetService(); + _oauthApplicationRepository = GetService(); + _tokenRepository = GetService(); } protected override async Task ResetDataAsync() @@ -305,6 +312,102 @@ public async Task GetCurrentUserAsync_TestOrganizationUser_ReturnsCurrentUser() Assert.True(user.IsActive); } + [Fact] + public async Task GetOAuthGrantsAsync_WithActiveOAuthTokens_ReturnsGroupedApplications() + { + // Arrange + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + + const string clientId = "test-oauth-grant-client"; + await CreateOAuthApplicationAsync(clientId, "Test AI Client"); + await CreateOAuthGrantTokenAsync(user.Id, clientId, "http://localhost:7110/mcp", [AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead, AuthorizationRoles.OfflineAccess]); + await CreateOAuthGrantTokenAsync(user.Id, clientId, "http://localhost:7110/api/v2", [AuthorizationRoles.ProjectsRead, AuthorizationRoles.StacksRead]); + await CreateOAuthGrantTokenAsync(user.Id, "disabled-oauth-grant-client", "http://localhost:7110/mcp", [AuthorizationRoles.McpRead], isDisabled: true); + + // Act + var grants = await SendRequestAsAsync>(r => r + .AsTestOrganizationUser() + .AppendPath("users/me/oauth-grants") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(grants); + var grant = Assert.Single(grants); + Assert.Equal(clientId, grant.ClientId); + Assert.Equal("Test AI Client", grant.ApplicationName); + Assert.Contains(SampleDataService.TEST_ORG_ID, grant.OrganizationIds); + Assert.Contains(AuthorizationRoles.McpRead, grant.Scopes); + Assert.Contains(AuthorizationRoles.StacksRead, grant.Scopes); + Assert.Equal(2, grant.Resources.Count); + Assert.Contains(grant.Resources, r => r.Resource == "http://localhost:7110/mcp" && r.Scopes.Contains(AuthorizationRoles.McpRead)); + Assert.Contains(grant.Resources, r => r.Resource == "http://localhost:7110/api/v2" && r.Scopes.Contains(AuthorizationRoles.StacksRead)); + } + + [Fact] + public async Task RevokeOAuthGrantAsync_WithCurrentUserGrant_DisablesAllClientTokens() + { + // Arrange + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + + const string clientId = "test-revoke-client"; + await CreateOAuthApplicationAsync(clientId, "Revoked AI Client"); + await CreateOAuthApplicationAsync("unrelated-revoke-client", "Unrelated AI Client"); + var firstToken = await CreateOAuthGrantTokenAsync(user.Id, clientId, "http://localhost:7110/mcp", [AuthorizationRoles.McpRead, AuthorizationRoles.OfflineAccess]); + var secondToken = await CreateOAuthGrantTokenAsync(user.Id, clientId, "http://localhost:7110/api/v2", [AuthorizationRoles.ProjectsRead, AuthorizationRoles.OfflineAccess]); + var unrelatedToken = await CreateOAuthGrantTokenAsync(user.Id, "unrelated-revoke-client", "http://localhost:7110/mcp", [AuthorizationRoles.McpRead, AuthorizationRoles.OfflineAccess]); + + // Act + await SendRequestAsync(r => r + .Delete() + .AsTestOrganizationUser() + .AppendPaths("users", "me", "oauth-grants", firstToken.Id) + .StatusCodeShouldBeNoContent() + ); + + // Assert + var revokedFirstToken = await _tokenRepository.GetByIdAsync(firstToken.Id, o => o.ImmediateConsistency()); + var revokedSecondToken = await _tokenRepository.GetByIdAsync(secondToken.Id, o => o.ImmediateConsistency()); + var stillActiveToken = await _tokenRepository.GetByIdAsync(unrelatedToken.Id, o => o.ImmediateConsistency()); + Assert.NotNull(revokedFirstToken); + Assert.NotNull(revokedSecondToken); + Assert.NotNull(stillActiveToken); + Assert.True(revokedFirstToken.IsDisabled); + Assert.True(revokedSecondToken.IsDisabled); + Assert.Null(revokedFirstToken.Refresh); + Assert.Null(revokedSecondToken.Refresh); + Assert.False(stillActiveToken.IsDisabled); + Assert.NotNull(stillActiveToken.Refresh); + } + + [Fact] + public async Task RevokeOAuthGrantAsync_ForAnotherUserGrant_ReturnsNotFound() + { + // Arrange + var freeUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.FREE_USER_EMAIL); + Assert.NotNull(freeUser); + + const string clientId = "other-user-revoke-client"; + await CreateOAuthApplicationAsync(clientId, "Other User AI Client"); + var token = await CreateOAuthGrantTokenAsync(freeUser.Id, clientId, "http://localhost:7110/mcp", [AuthorizationRoles.McpRead, AuthorizationRoles.OfflineAccess], organizationIds: [SampleDataService.FREE_ORG_ID]); + + // Act + await SendRequestAsync(r => r + .Delete() + .AsTestOrganizationUser() + .AppendPaths("users", "me", "oauth-grants", token.Id) + .StatusCodeShouldBeNotFound() + ); + + // Assert + var storedToken = await _tokenRepository.GetByIdAsync(token.Id, o => o.ImmediateConsistency()); + Assert.NotNull(storedToken); + Assert.False(storedToken.IsDisabled); + Assert.NotNull(storedToken.Refresh); + } + [Fact] public async Task UploadAvatarAsync_ImageOverGlobalRequestLimit_ReturnsUpdatedUser() { @@ -566,6 +669,58 @@ public Task VerifyAsync_InvalidToken_ReturnsNotFound() ); } + private Task CreateOAuthApplicationAsync(string clientId, string name) + { + var utcNow = TimeProvider.GetUtcNow().UtcDateTime; + var application = new OAuthApplication + { + Id = ObjectId.GenerateNewId().ToString(), + ClientId = clientId, + Name = name, + RedirectUris = ["http://localhost/callback"], + Scopes = + [ + AuthorizationRoles.McpRead, + AuthorizationRoles.ProjectsRead, + AuthorizationRoles.StacksRead, + AuthorizationRoles.StacksWrite, + AuthorizationRoles.EventsRead, + AuthorizationRoles.OfflineAccess + ], + CreatedByUserId = OAuthApplication.SystemUserId, + UpdatedByUserId = OAuthApplication.SystemUserId, + CreatedUtc = utcNow, + UpdatedUtc = utcNow + }; + + return _oauthApplicationRepository.AddAsync(application, o => o.ImmediateConsistency()); + } + + private Task CreateOAuthGrantTokenAsync(string userId, string clientId, string resource, string[] scopes, bool isDisabled = false, string[]? organizationIds = null) + { + var utcNow = TimeProvider.GetUtcNow().UtcDateTime; + var token = new Token + { + Id = StringExtensions.GetNewToken(), + UserId = userId, + Type = TokenType.Access, + OAuthType = OAuthTokenType.Access, + OAuthClientId = clientId, + OAuthResource = resource, + Refresh = scopes.Contains(AuthorizationRoles.OfflineAccess, StringComparer.Ordinal) ? StringExtensions.GetNewToken() : null, + Scopes = scopes.ToHashSet(StringComparer.Ordinal), + OAuthOrganizationIds = (organizationIds ?? [SampleDataService.TEST_ORG_ID]).ToHashSet(StringComparer.Ordinal), + ExpiresUtc = utcNow.AddHours(1), + OAuthRefreshExpiresUtc = scopes.Contains(AuthorizationRoles.OfflineAccess, StringComparer.Ordinal) ? utcNow.AddDays(30) : null, + IsDisabled = isDisabled, + CreatedBy = userId, + CreatedUtc = utcNow, + UpdatedUtc = utcNow + }; + + return _tokenRepository.AddAsync(token, o => o.ImmediateConsistency()); + } + private async Task GetTestOrganizationUserAsync() { var user = await SendRequestAsAsync(r => r diff --git a/tests/http/users.http b/tests/http/users.http index 7321de24f..38b9d2cee 100644 --- a/tests/http/users.http +++ b/tests/http/users.http @@ -24,7 +24,15 @@ Authorization: Bearer {{token}} ### @userId = {{currentUser.response.body.$.id}} @avatarFile = ./avatar.png +@oauthGrantId = replace-with-oauth-grant-id +### Get OAuth Grants +GET {{apiUrl}}/users/me/oauth-grants +Authorization: Bearer {{token}} + +### Revoke OAuth Grant +DELETE {{apiUrl}}/users/me/oauth-grants/{{oauthGrantId}} +Authorization: Bearer {{token}} ### Upload Avatar # Replace {{avatarFile}} with a local PNG, JPEG, GIF, or WebP image. POST {{apiUrl}}/users/{{userId}}/avatar From a7374cf8e2b745556ef1b8681a49bcaff3140346 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 27 Jun 2026 14:23:31 -0500 Subject: [PATCH 05/23] Allow OAuth consent scope reduction --- .../(auth)/oauth/authorize/+page.svelte | 114 ++++++++++++++++-- .../Controllers/OAuthControllerTests.cs | 18 +++ 2 files changed, 123 insertions(+), 9 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte index ef79d1d29..53950c3a2 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte @@ -23,9 +23,13 @@ redirect_uri?: string; } + const offlineAccessScope = 'offline_access'; + const mcpReadScope = 'mcp:read'; + let errorMessage = $state(null); let isAuthorizing = $state(false); const selectedOrganizationIds = new SvelteSet(); + const selectedScopes = new SvelteSet(); const meQuery = getMeQuery(); const organizationsQuery = getOrganizationsQuery({ params: { mode: null } }); @@ -36,7 +40,13 @@ const accountDisplayName = $derived(meQuery.data?.full_name || meQuery.data?.email_address || 'Unknown account'); const organizations = $derived(organizationsQuery.data?.data ?? []); const requestedScopes = $derived(getRequestedScopes()); + const requiredScopes = $derived(getRequiredScopes(resource)); + const missingRequiredScopes = $derived(requiredScopes.filter((scope) => !requestedScopes.includes(scope))); + const selectedScopeValues = $derived(getSelectedScopesInRequestOrder()); const hasSelectedOrganizations = $derived(selectedOrganizationIds.size > 0); + const hasSelectedResourceScope = $derived(selectedScopeValues.some((scope) => scope !== offlineAccessScope)); + const hasRequiredScopes = $derived(missingRequiredScopes.length === 0 && requiredScopes.every((scope) => selectedScopes.has(scope))); + const canApprove = $derived(hasSelectedOrganizations && hasSelectedResourceScope && hasRequiredScopes); $effect(() => { if (!browser || accessToken.current) { @@ -69,6 +79,13 @@ } }); + $effect(() => { + selectedScopes.clear(); + for (const scope of requestedScopes) { + selectedScopes.add(scope); + } + }); + async function approveAuthorization(): Promise { if (isAuthorizing) { return; @@ -79,6 +96,16 @@ return; } + if (!hasRequiredScopes) { + errorMessage = `Missing required scope: ${missingRequiredScopes.map(formatScope).join(', ')}.`; + return; + } + + if (!hasSelectedResourceScope) { + errorMessage = 'Select at least one access scope.'; + return; + } + isAuthorizing = true; errorMessage = null; const client = useFetchClient(); @@ -92,7 +119,7 @@ redirect_uri: page.url.searchParams.get('redirect_uri'), resource: page.url.searchParams.get('resource'), response_type: page.url.searchParams.get('response_type'), - scope: page.url.searchParams.get('scope'), + scope: selectedScopeValues.join(' '), state: page.url.searchParams.get('state') }, { expectedStatusCodes: [400, 401] } @@ -131,6 +158,22 @@ return scopes; } + function getRequiredScopes(resourceValue: string): string[] { + if (resourceValue.endsWith('/mcp')) { + return [mcpReadScope]; + } + + return []; + } + + function getSelectedScopesInRequestOrder(): string[] { + return requestedScopes.filter((scope) => selectedScopes.has(scope)); + } + + function isRequiredScope(scope: string): boolean { + return requiredScopes.includes(scope); + } + function toggleOrganization(organizationId: string | undefined, checked: 'indeterminate' | boolean): void { if (!organizationId) { return; @@ -143,6 +186,38 @@ } } + function toggleScope(scope: string, checked: 'indeterminate' | boolean): void { + if (isRequiredScope(scope)) { + selectedScopes.add(scope); + return; + } + + if (checked === true) { + selectedScopes.add(scope); + } else { + selectedScopes.delete(scope); + } + } + + function formatScope(scope: string): string { + switch (scope) { + case 'events:read': + return 'Events Read'; + case mcpReadScope: + return 'MCP'; + case offlineAccessScope: + return 'Offline Access'; + case 'projects:read': + return 'Projects Read'; + case 'stacks:read': + return 'Stacks Read'; + case 'stacks:write': + return 'Stacks Write'; + default: + return scope; + } + } + function cancelAuthorization() { errorMessage = 'Authorization canceled. You can close this tab.'; } @@ -205,17 +280,38 @@ Resource

{resource}

-
+
Scopes -
- {#if requestedScopes.length > 0} + {#if requestedScopes.length > 0} +
{#each requestedScopes as scope (scope)} - {scope} + {/each} - {:else} -

No scopes requested.

+
+ {#if !hasSelectedResourceScope} +

Select at least one access scope.

+ {/if} + {#if missingRequiredScopes.length > 0} +

Missing required scope: {missingRequiredScopes.map(formatScope).join(', ')}.

{/if} -
+ {:else} +

No scopes requested.

+ {/if}
@@ -228,7 +324,7 @@ + {/snippet} + + + (revokeDialogOpen = true)} disabled={revokeGrant.isPending}> + + + + + + + + Revoke Application Access + + Revoke access for "{grant.application_name}"? The application will need to complete OAuth again before it can access your account. + + + + Cancel + void revokeAccess()}> + Revoke Access + + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-application-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-application-cell.svelte new file mode 100644 index 000000000..504742ce0 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-application-cell.svelte @@ -0,0 +1,19 @@ + + +
+
{grant.application_name}
+
{grant.client_id}
+ {#if grant.is_application_disabled} + Disabled + {/if} +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-organizations-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-organizations-cell.svelte new file mode 100644 index 000000000..ba9c85821 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-organizations-cell.svelte @@ -0,0 +1,26 @@ + + +{#if grant.organization_ids.length > 0} +
+ {#each grant.organization_ids as organizationId (organizationId)} + {formatOrganization(organizationId)} + {/each} +
+{:else} + - +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grants-data-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grants-data-table.svelte new file mode 100644 index 000000000..85301edcd --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grants-data-table.svelte @@ -0,0 +1,44 @@ + + + + {#if toolbarChildren} + + {@render toolbarChildren()} + + {:else} + + {/if} + + {#if isLoading} + + + + {:else} + No applications have access to your account. + {/if} + + + + +
+ + +
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/options.svelte.ts new file mode 100644 index 000000000..f26ee4e75 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/options.svelte.ts @@ -0,0 +1,88 @@ +import type { OAuthGrant } from '$features/users/models'; +import type { ProblemDetails } from '@exceptionless/fetchclient'; +import type { CreateQueryResult } from '@tanstack/svelte-query'; + +import DateTime from '$comp/formatters/date-time.svelte'; +import { getSharedTableOptions, type TableMemoryPagingParameters } from '$features/shared/table.svelte'; +import OAuthGrantAccessCell from '$features/users/components/oauth-grants/table/oauth-grant-access-cell.svelte'; +import OAuthGrantActionsCell from '$features/users/components/oauth-grants/table/oauth-grant-actions-cell.svelte'; +import OAuthGrantApplicationCell from '$features/users/components/oauth-grants/table/oauth-grant-application-cell.svelte'; +import OAuthGrantOrganizationsCell from '$features/users/components/oauth-grants/table/oauth-grant-organizations-cell.svelte'; +import { type ColumnDef, renderComponent, type StockFeatures } from '@tanstack/svelte-table'; + +export function getColumns(organizationNamesById: ReadonlyMap): ColumnDef[] { + const columns: ColumnDef[] = [ + { + accessorKey: 'application_name', + cell: (info) => renderComponent(OAuthGrantApplicationCell, { grant: info.row.original }), + enableHiding: false, + enableSorting: false, + header: 'Application', + meta: { + class: 'w-72' + } + }, + { + accessorKey: 'resources', + cell: (info) => renderComponent(OAuthGrantAccessCell, { grant: info.row.original }), + enableHiding: true, + enableSorting: false, + header: 'Access', + meta: { + class: 'w-96' + } + }, + { + accessorKey: 'organization_ids', + cell: (info) => renderComponent(OAuthGrantOrganizationsCell, { grant: info.row.original, organizationNamesById }), + enableHiding: true, + enableSorting: false, + header: 'Organizations', + meta: { + class: 'w-64' + } + }, + { + accessorKey: 'updated_utc', + cell: (info) => renderComponent(DateTime, { value: info.getValue() }), + enableHiding: true, + enableSorting: false, + header: 'Updated', + meta: { + class: 'w-48 whitespace-nowrap' + } + }, + { + cell: (info) => renderComponent(OAuthGrantActionsCell, { grant: info.row.original }), + enableHiding: false, + enableSorting: false, + header: '', + id: 'actions', + meta: { + class: 'w-12 min-w-12 max-w-12 text-right' + } + } + ]; + + return columns; +} + +export function getTableOptions( + queryParameters: TableMemoryPagingParameters, + queryResponse: CreateQueryResult, + getOrganizationNamesById: () => ReadonlyMap +) { + return getSharedTableOptions({ + columnPersistenceKey: 'oauth-grants', + get columns() { + return getColumns(getOrganizationNamesById()); + }, + paginationStrategy: 'memory', + get queryData() { + return queryResponse.data ?? []; + }, + get queryParameters() { + return queryParameters; + } + }); +} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte index b22940e24..42e4337ac 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte @@ -5,7 +5,7 @@ import * as Card from '$comp/ui/card'; import * as Select from '$comp/ui/select'; - type AiToolId = 'claude' | 'codex' | 'opencode'; + type AiToolId = 'claude' | 'codex' | 'github-copilot' | 'opencode'; type CommandStep = { code: string; @@ -80,6 +80,25 @@ } ] }, + { + description: 'Use GitHub Copilot CLI with the hosted Exceptionless MCP server.', + id: 'github-copilot', + name: 'GitHub Copilot CLI', + steps: [ + { + code: `gh copilot -- mcp add --transport http exceptionless ${mcpEndpoint}`, + description: 'Register the hosted HTTP MCP server with Copilot.', + language: 'shellscript', + title: 'Add the server' + }, + { + code: 'gh copilot -- -i "List my Exceptionless projects"', + description: 'Start Copilot and approve the OAuth browser flow when prompted.', + language: 'shellscript', + title: 'Authenticate' + } + ] + }, { description: 'Use OpenCode with the hosted Exceptionless MCP server.', id: 'opencode', diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/applications/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/applications/+page.svelte index 41c2eca31..e53ac8f30 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/applications/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/applications/+page.svelte @@ -1,182 +1,72 @@
-
- Manage applications connected to your account -
- + Manage applications connected to your account - {#if grantsQuery.isPending} -
- - Loading applications... -
- {:else if grantsQuery.isError} + {#if grantsQuery.isError}

Failed to load applications.

- {:else if grants.length === 0} -

No applications have access to your account.

{:else} -
- - - - Application - Access - Organizations - Updated - Actions - - - - {#each grants as grant (grant.id)} - - -
{grant.application_name}
-
{grant.client_id}
- {#if grant.is_application_disabled} - Disabled - {/if} -
- -
- {#each grant.resources as resource (resource.resource)} -
-
{formatResource(resource.resource)}
-
- {#each resource.scopes as scope (scope)} - {formatScope(scope)} - {/each} -
-
- {/each} -
-
- -
- {#each grant.organization_ids as organizationId (organizationId)} - {formatOrganization(organizationId)} - {/each} -
-
- - - - - - -
- {/each} -
-
-
+ + {#snippet toolbarChildren()} +
+ + {/snippet} +
{/if}
- - - - - Revoke Application Access - - Revoke access for "{selectedGrant?.application_name}"? The application will need to complete OAuth again before it can access your account. - - - - Cancel - void revokeSelectedGrant()}> - Revoke Access - - - - diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/+layout.svelte index a378ce01d..dd1a81275 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/+layout.svelte @@ -2,6 +2,8 @@ let { children } = $props(); -
- {@render children()} +
+
+ {@render children()} +
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte index eed40c9ee..ed1a55b98 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte @@ -243,7 +243,7 @@ function getRequiredScopes(resourceValue: string): string[] { if (resourceValue.endsWith('/mcp')) { - return [mcpReadScope]; + return [mcpReadScope, offlineAccessScope]; } return []; @@ -306,15 +306,15 @@ } -
- +
+ - + Approve OAuth access Review the requested Exceptionless access before continuing. - -
+ +
Signed in as {#if meQuery.isLoading} @@ -322,8 +322,8 @@ {:else if meQuery.isError}

Unable to load account details.

{:else} -

{accountDisplayName}

-

{meQuery.data?.email_address}

+

{accountDisplayName}

+

{meQuery.data?.email_address}

{/if}
@@ -333,9 +333,9 @@ {:else if organizationsQuery.isError}

Unable to load organizations.

{:else if organizations.length > 0} -
+
{#each organizations as organization (organization.id)} -
-
-
+
+
Application {#if isLoadingConsent}

Loading application...

{:else} -

{applicationDisplayName}

+

{applicationDisplayName}

{#if applicationClientId !== applicationDisplayName} -

{applicationClientId}

+

{applicationClientId}

{/if} {/if}
-
+
Redirect URI -

{displayRedirectUri}

+

{displayRedirectUri}

-
+
Resource -

{displayResource}

+

{displayResource}

-
- Scopes - {#if requestedScopes.length > 0} -
- {#each requestedScopes as scope (scope)} -
+ +
+ Scopes + {#if requestedScopes.length > 0} +
+ {#each requestedScopes as scope (scope)} + - {/each} -
- {#if !hasSelectedResourceScope} -

Select at least one access scope.

- {/if} - {#if missingRequiredScopes.length > 0} -

Missing required scope: {missingRequiredScopes.map(formatScope).join(', ')}.

- {/if} - {:else} -

No scopes requested.

+ {scope} + + + {/each} +
+ {#if !hasSelectedResourceScope} +

Select at least one access scope.

{/if} -
+ {#if missingRequiredScopes.length > 0} +

Missing required scope: {missingRequiredScopes.map(formatScope).join(', ')}.

+ {/if} + {:else} +

No scopes requested.

+ {/if}
{#if errorMessage || consentErrorMessage} diff --git a/tests/Exceptionless.Tests/Controllers/OAuthControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OAuthControllerTests.cs index 587af23c6..5f41775d8 100644 --- a/tests/Exceptionless.Tests/Controllers/OAuthControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OAuthControllerTests.cs @@ -136,7 +136,7 @@ public async Task RegisterAsync_WithoutScope_DefaultsToReadOnlyScopes() Assert.Contains(AuthorizationRoles.StacksRead, registration.Scope); Assert.Contains(AuthorizationRoles.EventsRead, registration.Scope); Assert.DoesNotContain(AuthorizationRoles.StacksWrite, registration.Scope); - Assert.DoesNotContain(AuthorizationRoles.OfflineAccess, registration.Scope); + Assert.Contains(AuthorizationRoles.OfflineAccess, registration.Scope); var application = await _oauthApplicationRepository.GetByClientIdAsync(registration.ClientId, o => o.ImmediateConsistency()); Assert.NotNull(application); @@ -145,7 +145,7 @@ public async Task RegisterAsync_WithoutScope_DefaultsToReadOnlyScopes() Assert.Contains(AuthorizationRoles.StacksRead, application.Scopes); Assert.Contains(AuthorizationRoles.EventsRead, application.Scopes); Assert.DoesNotContain(AuthorizationRoles.StacksWrite, application.Scopes); - Assert.DoesNotContain(AuthorizationRoles.OfflineAccess, application.Scopes); + Assert.Contains(AuthorizationRoles.OfflineAccess, application.Scopes); } [Fact] @@ -218,6 +218,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); } [Fact] @@ -538,7 +539,7 @@ await CreateAuthorizationCodeAsync( Assert.Contains(AuthorizationRoles.StacksRead, application.Scopes); Assert.Contains(AuthorizationRoles.EventsRead, application.Scopes); Assert.DoesNotContain(AuthorizationRoles.StacksWrite, application.Scopes); - Assert.DoesNotContain(AuthorizationRoles.OfflineAccess, application.Scopes); + Assert.Contains(AuthorizationRoles.OfflineAccess, application.Scopes); } [Fact] @@ -646,22 +647,22 @@ public async Task TokenAsync_ValidAuthorizationCode_ReturnsOAuthTokens() [Fact] public async Task TokenAsync_AuthorizationCodeWithReducedScopes_IssuesSelectedScopes() { - var token = await IssueTokenAsync(scope: $"{AuthorizationRoles.McpRead} {AuthorizationRoles.ProjectsRead}"); + var token = await IssueTokenAsync(scope: $"{AuthorizationRoles.McpRead} {AuthorizationRoles.ProjectsRead} {AuthorizationRoles.OfflineAccess}"); Assert.NotNull(token); - Assert.Equal($"{AuthorizationRoles.McpRead} {AuthorizationRoles.ProjectsRead}", token.Scope); - Assert.Null(token.RefreshToken); + Assert.Equal($"{AuthorizationRoles.McpRead} {AuthorizationRoles.ProjectsRead} {AuthorizationRoles.OfflineAccess}", token.Scope); + Assert.NotNull(token.RefreshToken); var storedToken = await GetStoredOAuthTokenAsync(token.AccessToken); Assert.NotNull(storedToken); Assert.NotEqual(token.AccessToken, storedToken.Id); Assert.Equal(OAuthService.CreateTokenHash(token.AccessToken), storedToken.AccessTokenHash); - Assert.Null(storedToken.RefreshTokenHash); + Assert.Equal(OAuthService.CreateTokenHash(token.RefreshToken), storedToken.RefreshTokenHash); Assert.Contains(AuthorizationRoles.McpRead, storedToken.Scopes); Assert.Contains(AuthorizationRoles.ProjectsRead, storedToken.Scopes); Assert.DoesNotContain(AuthorizationRoles.StacksWrite, storedToken.Scopes); Assert.DoesNotContain(AuthorizationRoles.EventsRead, storedToken.Scopes); - Assert.DoesNotContain(AuthorizationRoles.OfflineAccess, storedToken.Scopes); + Assert.Contains(AuthorizationRoles.OfflineAccess, storedToken.Scopes); } [Fact] From 0b74944d1e55c928737b60c94076d14c85c70ae4 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 28 Jun 2026 22:07:34 -0500 Subject: [PATCH 14/23] Show required OAuth scopes without toggles --- .../(auth)/oauth/authorize/+page.svelte | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte index ed1a55b98..3874bc8c5 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte @@ -73,6 +73,8 @@ const requestedScopes = $derived(consentDetails?.scopes ?? getRequestedScopes()); const requiredScopes = $derived(consentDetails?.required_scopes ?? getRequiredScopes(displayResource)); const missingRequiredScopes = $derived(requiredScopes.filter((scope) => !requestedScopes.includes(scope))); + const requestedRequiredScopes = $derived(requestedScopes.filter((scope) => isRequiredScope(scope))); + const requestedOptionalScopes = $derived(requestedScopes.filter((scope) => !isRequiredScope(scope))); const selectedScopeValues = $derived(getSelectedScopesInRequestOrder()); const hasSelectedOrganizations = $derived(selectedOrganizationIds.size > 0); const hasSelectedResourceScope = $derived(selectedScopeValues.some((scope) => scope !== offlineAccessScope)); @@ -376,22 +378,24 @@ Scopes {#if requestedScopes.length > 0}
- {#each requestedScopes as scope (scope)} -
From 0b1a8e56a898e90bca58d24a03ff19f139125b81 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 28 Jun 2026 22:28:18 -0500 Subject: [PATCH 15/23] Fix table helper for locked TanStack version --- .../src/lib/features/shared/table.svelte.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts index 1910fee4f..f08d45921 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts @@ -243,7 +243,6 @@ export function getSharedTableOptions = { - _features: sharedTableFeatures, get columns() { return columns(); }, @@ -259,6 +258,7 @@ export function getSharedTableOptions { return originalRow && typeof originalRow === 'object' && 'id' in originalRow && originalRow.id != null ? String(originalRow.id) @@ -426,12 +426,15 @@ export function resolvePaginationChange(previousPageInfo: PaginationState, curre } export function withClientSortedRowModel(options: TableOptions): TableOptions { + const features = tableFeatures({ + ...options.features, + sortedRowModel: createSortedRowModel(), + sortFns + }); + return { ...options, - _rowModels: { - ...options._rowModels, - sortedRowModel: createSortedRowModel(sortFns) - } + features }; } From bb712cc85982e59a72fa52b369d7f10908b6c589 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 28 Jun 2026 23:03:06 -0500 Subject: [PATCH 16/23] Use standalone Copilot CLI instructions --- .../ClientApp/src/routes/(app)/account/ai-tools/+page.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte index 42e4337ac..16b13046b 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte @@ -86,13 +86,13 @@ name: 'GitHub Copilot CLI', steps: [ { - code: `gh copilot -- mcp add --transport http exceptionless ${mcpEndpoint}`, + code: `copilot mcp add --transport http exceptionless ${mcpEndpoint}`, description: 'Register the hosted HTTP MCP server with Copilot.', language: 'shellscript', title: 'Add the server' }, { - code: 'gh copilot -- -i "List my Exceptionless projects"', + code: 'copilot -i "List my Exceptionless projects"', description: 'Start Copilot and approve the OAuth browser flow when prompted.', language: 'shellscript', title: 'Authenticate' From 2020f091cfbcaa90d1ff921b992f7aaf5ea3f796 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 28 Jun 2026 23:42:01 -0500 Subject: [PATCH 17/23] Fix MCP output schema optional fields --- .../(app)/account/ai-tools/+page.svelte | 19 ++---- .../Mcp/ExceptionlessMcpTools.cs | 50 +++++++------- src/Exceptionless.Web/Mcp/McpModels.cs | 42 ++++++------ .../Controllers/ExceptionlessMcpToolsTests.cs | 67 +++++++++++++++++++ 4 files changed, 115 insertions(+), 63 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte index 16b13046b..4bcb43e02 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte @@ -30,17 +30,6 @@ } }); - const openCodeConfiguration = $derived(`{ - "$schema": "https://opencode.ai/config.json", - "mcp": { - "exceptionless": { - "type": "remote", - "url": "${mcpEndpoint}", - "oauth": {} - } - } -}`); - const aiTools = $derived([ { description: 'Use Claude Code with the hosted Exceptionless MCP server.', @@ -105,10 +94,10 @@ name: 'OpenCode', steps: [ { - code: openCodeConfiguration, - description: 'Add this server entry to your opencode.json file.', - language: 'json', - title: 'Add the server configuration' + code: `opencode mcp add exceptionless --url ${mcpEndpoint}`, + description: 'Register the hosted HTTP MCP server with OpenCode.', + language: 'shellscript', + title: 'Add the server' }, { code: 'opencode mcp auth exceptionless', diff --git a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs index b2fe33100..d78593093 100644 --- a/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs +++ b/src/Exceptionless.Web/Mcp/ExceptionlessMcpTools.cs @@ -562,12 +562,12 @@ public async Task> CountEventsAsync( GetNumericAggregationValue(result.Aggregations.Sum("sum_count")?.Value, result.Total), Convert.ToInt64(result.Aggregations.Cardinality("cardinality_stack_id")?.Value.GetValueOrDefault() ?? 0, CultureInfo.InvariantCulture), Convert.ToInt64(result.Aggregations.Cardinality("cardinality_user")?.Value.GetValueOrDefault() ?? 0, CultureInfo.InvariantCulture), - interval, - timeRange.StartUtc, - timeRange.EndUtc, buckets, - resolvedGroupBy?.Name, - groups), + Interval: interval, + StartUtc: timeRange.StartUtc, + EndUtc: timeRange.EndUtc, + GroupBy: resolvedGroupBy?.Name, + Groups: groups), groupLimitWarning); } catch (Exception ex) when (IsLookupError(ex)) @@ -1334,11 +1334,11 @@ private static McpProjectResult ToProjectResult(Project project) project.Id, project.OrganizationId, project.Name, - project.IsConfigured, - project.LastEventDateUtc, project.CreatedUtc, project.UpdatedUtc, - $"/api/v2/projects/{project.Id}"); + $"/api/v2/projects/{project.Id}", + project.IsConfigured, + project.LastEventDateUtc); } private static McpStackResult ToStackResult(Stack stack) @@ -1350,19 +1350,19 @@ private static McpStackResult ToStackResult(Stack stack) stack.Type, stack.Status.ToString().ToLowerInvariant(), stack.Title, - stack.Description, stack.TotalOccurrences, stack.FirstOccurrence, stack.LastOccurrence, - stack.DateFixed, - stack.FixedInVersion, - stack.SnoozeUntilUtc, ToTags(stack.Tags), stack.References.ToArray(), stack.OccurrencesAreCritical, stack.CreatedUtc, stack.UpdatedUtc, - $"/api/v2/stacks/{stack.Id}"); + $"/api/v2/stacks/{stack.Id}", + stack.Description, + stack.DateFixed, + stack.FixedInVersion, + stack.SnoozeUntilUtc); } private McpEventResult ToEventResult(PersistentEvent ev, bool includeDetails = false, int maxDetailSize = DefaultMaxDetailSize) @@ -1372,25 +1372,25 @@ private McpEventResult ToEventResult(PersistentEvent ev, bool includeDetails = f ev.OrganizationId, ev.ProjectId, ev.StackId, - ev.Type, - ev.Source, - ev.Message, ev.Date, ToTags(ev.Tags), - ev.ReferenceId, ev.IsFirstOccurrence, ev.CreatedUtc, $"/api/v2/events/{ev.Id}", + ev.Type, + ev.Source, + ev.Message, + ev.ReferenceId, includeDetails ? ToEventDetails(ev, maxDetailSize) : null); } private McpEventDetails ToEventDetails(PersistentEvent ev, int maxDetailSize) { var details = new McpEventDetails( - ev.GetError(_serializer, _logger) ?? (object?)ev.GetSimpleError(_serializer, _logger), - ev.GetRequestInfo(_serializer, _logger), - ev.GetEnvironmentInfo(_serializer, _logger), - ev.Data); + Error: ev.GetError(_serializer, _logger) ?? (object?)ev.GetSimpleError(_serializer, _logger), + Request: ev.GetRequestInfo(_serializer, _logger), + Environment: ev.GetEnvironmentInfo(_serializer, _logger), + Data: ev.Data); return ApplyDetailLimit(details, maxDetailSize); } @@ -1414,10 +1414,6 @@ private McpEventDetails ApplyDetailLimit(McpEventDetails details, int maxDetailS return withoutData; return new McpEventDetails( - null, - null, - null, - null, true, originalSize, maxDetailSize, @@ -1514,9 +1510,9 @@ private McpResponse> ToListResponse( warning, new McpPagination( results.HasMore, + limit, results.Hits.FirstOrDefault()?.GetSortToken(_serializer), - results.HasMore ? results.Hits.LastOrDefault()?.GetSortToken(_serializer) : null, - limit)); + results.HasMore ? results.Hits.LastOrDefault()?.GetSortToken(_serializer) : null)); } private static IRepositoryQuery ApplyStackTimeRange(IRepositoryQuery query, McpTimeRange timeRange) diff --git a/src/Exceptionless.Web/Mcp/McpModels.cs b/src/Exceptionless.Web/Mcp/McpModels.cs index 530263036..89e15a1f5 100644 --- a/src/Exceptionless.Web/Mcp/McpModels.cs +++ b/src/Exceptionless.Web/Mcp/McpModels.cs @@ -20,7 +20,7 @@ public static McpResponse Failed(McpErrorInfo error) public sealed record McpListData(IReadOnlyCollection Items); -public sealed record McpPagination(bool HasMore, string? Before, string? After, int Limit); +public sealed record McpPagination(bool HasMore, int Limit, string? Before = null, string? After = null); public sealed record McpTimeRange(DateTime? StartUtc, DateTime? EndUtc) { @@ -42,10 +42,10 @@ public sealed record McpEventCountResult( double Occurrences, long Stacks, long Users, - string? Interval, - DateTime? StartUtc, - DateTime? EndUtc, IReadOnlyCollection Trend, + string? Interval = null, + DateTime? StartUtc = null, + DateTime? EndUtc = null, string? GroupBy = null, IReadOnlyCollection? Groups = null); @@ -64,11 +64,11 @@ public sealed record McpProjectResult( string Id, string OrganizationId, string Name, - bool? IsConfigured, - DateTime? LastEventDateUtc, DateTime CreatedUtc, DateTime UpdatedUtc, - string Url); + string Url, + bool? IsConfigured = null, + DateTime? LastEventDateUtc = null); public sealed record McpClientSetupInstructionsResult( string ProjectId, @@ -95,19 +95,19 @@ public sealed record McpStackResult( string Type, string Status, string Title, - string? Description, int TotalOccurrences, DateTime FirstOccurrence, DateTime LastOccurrence, - DateTime? DateFixed, - string? FixedInVersion, - DateTime? SnoozeUntilUtc, IReadOnlyCollection Tags, IReadOnlyCollection References, bool OccurrencesAreCritical, DateTime CreatedUtc, DateTime UpdatedUtc, - string Url); + string Url, + string? Description = null, + DateTime? DateFixed = null, + string? FixedInVersion = null, + DateTime? SnoozeUntilUtc = null); public sealed record McpStackUpdateResult( McpStackResult Stack, @@ -119,23 +119,23 @@ public sealed record McpEventResult( string OrganizationId, string ProjectId, string StackId, - string? Type, - string? Source, - string? Message, DateTimeOffset Date, IReadOnlyCollection Tags, - string? ReferenceId, bool IsFirstOccurrence, DateTime CreatedUtc, string Url, + string? Type = null, + string? Source = null, + string? Message = null, + string? ReferenceId = null, McpEventDetails? Details = null); public sealed record McpEventDetails( - object? Error, - RequestInfo? Request, - EnvironmentInfo? Environment, - DataDictionary? Data, bool IsTruncated = false, int? Size = null, int? MaxSize = null, - string? TruncationMessage = null); + string? TruncationMessage = null, + object? Error = null, + RequestInfo? Request = null, + EnvironmentInfo? Environment = null, + DataDictionary? Data = null); diff --git a/tests/Exceptionless.Tests/Controllers/ExceptionlessMcpToolsTests.cs b/tests/Exceptionless.Tests/Controllers/ExceptionlessMcpToolsTests.cs index d37586e39..3e8f2bec5 100644 --- a/tests/Exceptionless.Tests/Controllers/ExceptionlessMcpToolsTests.cs +++ b/tests/Exceptionless.Tests/Controllers/ExceptionlessMcpToolsTests.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using System.Text.Json; using Exceptionless.Core.Authorization; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; @@ -751,6 +752,25 @@ public async Task PagedTools_InvalidCursor_ReturnsInvalidCursor(string methodNam Assert.Null(result.Data); } + [Fact] + public async Task ListProjectsAsync_OutputSchemaDoesNotRequireNullableFields() + { + var tools = await CreateToolsAsync(AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead); + var method = typeof(ExceptionlessMcpTools).GetMethod(nameof(ExceptionlessMcpTools.ListProjectsAsync)) ?? throw new InvalidOperationException("Could not find ListProjectsAsync."); + var tool = McpServerTool.Create(method, tools, new McpServerToolCreateOptions()); + var outputSchema = tool.ProtocolTool.OutputSchema ?? throw new InvalidOperationException("The list_projects tool must advertise an output schema."); + + var paginationSchema = GetSchemaProperty(outputSchema, outputSchema, "pagination"); + Assert.DoesNotContain("after", RequiredProperties(paginationSchema)); + Assert.DoesNotContain("before", RequiredProperties(paginationSchema)); + + var dataSchema = GetSchemaProperty(outputSchema, outputSchema, "data"); + var itemsSchema = GetSchemaProperty(outputSchema, dataSchema, "items"); + var projectSchema = ResolveSchema(outputSchema, itemsSchema.GetProperty("items")); + Assert.DoesNotContain("isConfigured", RequiredProperties(projectSchema)); + Assert.DoesNotContain("lastEventDateUtc", RequiredProperties(projectSchema)); + } + [Theory] [InlineData(nameof(ExceptionlessMcpTools.ListProjectsAsync))] [InlineData(nameof(ExceptionlessMcpTools.SearchStacksAsync))] @@ -943,6 +963,53 @@ private static IReadOnlyCollection Items(McpResponse> resul return result.Data!.Items; } + private static JsonElement GetSchemaProperty(JsonElement rootSchema, JsonElement schema, string propertyName) + { + schema = ResolveSchema(rootSchema, schema); + var propertySchema = schema.GetProperty("properties").GetProperty(propertyName); + + return ResolveSchema(rootSchema, propertySchema); + } + + private static JsonElement ResolveSchema(JsonElement rootSchema, JsonElement schema) + { + if (schema.TryGetProperty("$ref", out var reference)) + return ResolveSchemaReference(rootSchema, reference.GetString() ?? throw new InvalidOperationException("Schema reference is empty.")); + + if (schema.TryGetProperty("anyOf", out var anyOf)) + return anyOf.EnumerateArray().Select(candidate => ResolveSchema(rootSchema, candidate)).First(candidate => IsObjectSchema(candidate)); + + if (schema.TryGetProperty("oneOf", out var oneOf)) + return oneOf.EnumerateArray().Select(candidate => ResolveSchema(rootSchema, candidate)).First(candidate => IsObjectSchema(candidate)); + + return schema; + } + + private static JsonElement ResolveSchemaReference(JsonElement rootSchema, string reference) + { + if (!reference.StartsWith("#/", StringComparison.Ordinal)) + throw new InvalidOperationException($"Only local schema references are supported. Reference: {reference}"); + + var current = rootSchema; + foreach (string segment in reference[2..].Split('/')) + current = current.GetProperty(segment.Replace("~1", "/", StringComparison.Ordinal).Replace("~0", "~", StringComparison.Ordinal)); + + return ResolveSchema(rootSchema, current); + } + + private static IReadOnlyCollection RequiredProperties(JsonElement schema) + { + schema = ResolveSchema(schema, schema); + return schema.TryGetProperty("required", out var required) + ? required.EnumerateArray().Select(property => property.GetString()!).ToArray() + : []; + } + + private static bool IsObjectSchema(JsonElement schema) + { + return schema.TryGetProperty("type", out var type) && type.ValueKind == JsonValueKind.String && type.GetString() == "object"; + } + private static T Data(McpResponse result) { Assert.NotNull(result.Data); From 4c249b4efd646f7c0dfa63efefff6463316e7935 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 28 Jun 2026 23:54:00 -0500 Subject: [PATCH 18/23] Default OAuth consent organizations to selected --- .../src/routes/(auth)/oauth/authorize/+page.svelte | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte index 3874bc8c5..6d33638d6 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte @@ -55,6 +55,7 @@ let isAuthorizing = $state(false); let isLoadingConsent = $state(false); let loadedConsentKey = $state(null); + let initializedOrganizationSelectionKey = $state(null); const selectedOrganizationIds = new SvelteSet(); const selectedScopes = new SvelteSet(); @@ -115,6 +116,17 @@ return; } + const consentKey = page.url.search; + if (initializedOrganizationSelectionKey !== consentKey) { + selectedOrganizationIds.clear(); + for (const organizationId of organizationIds) { + selectedOrganizationIds.add(organizationId); + } + + initializedOrganizationSelectionKey = consentKey; + return; + } + const validSelectedOrganizationIds = [...selectedOrganizationIds].filter((id) => organizationIds.includes(id)); if (validSelectedOrganizationIds.length === selectedOrganizationIds.size) { return; From 4f0a0580eaf2cf58a30fc3937e86b7f5d11996f2 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 29 Jun 2026 00:16:15 -0500 Subject: [PATCH 19/23] Compact OAuth applications table --- .../table/oauth-grant-access-cell.svelte | 38 ++++++++++++++----- .../table/oauth-grant-application-cell.svelte | 6 ++- .../table/oauth-grants-data-table.svelte | 2 - .../oauth-grants/table/options.svelte.ts | 34 +++-------------- .../(app)/account/applications/+page.svelte | 8 +--- 5 files changed, 40 insertions(+), 48 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-access-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-access-cell.svelte index eddf45189..ef91a7284 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-access-cell.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-access-cell.svelte @@ -5,9 +5,14 @@ interface Props { grant: OAuthGrant; + organizationNamesById: ReadonlyMap; } - let { grant }: Props = $props(); + let { grant, organizationNamesById }: Props = $props(); + + function formatOrganization(id: string) { + return organizationNamesById.get(id) ?? id; + } function formatResource(resource: string) { if (resource.endsWith('/mcp')) { @@ -41,15 +46,30 @@ } -
- {#each grant.resources as resource (resource.resource)} -
-
{formatResource(resource.resource)}
+
+
+ {#each grant.resources as resource (resource.resource)} +
+
{formatResource(resource.resource)}
+
+ {#each resource.scopes as scope (scope)} + {formatScope(scope)} + {/each} +
+
+ {/each} +
+ +
+
Organizations
+ {#if grant.organization_ids.length > 0}
- {#each resource.scopes as scope (scope)} - {formatScope(scope)} + {#each grant.organization_ids as organizationId (organizationId)} + {formatOrganization(organizationId)} {/each}
-
- {/each} + {:else} + - + {/if} +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-application-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-application-cell.svelte index 504742ce0..ebada5f4f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-application-cell.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-application-cell.svelte @@ -1,6 +1,7 @@ -
+
{grant.application_name}
-
{grant.client_id}
+
{grant.client_id}
+
Updated
{#if grant.is_application_disabled} Disabled {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grants-data-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grants-data-table.svelte index 85301edcd..ba6a9c5d3 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grants-data-table.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grants-data-table.svelte @@ -21,8 +21,6 @@ {@render toolbarChildren()} - {:else} - {/if} {#if isLoading} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/options.svelte.ts index f26ee4e75..b7067c67a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/options.svelte.ts @@ -2,12 +2,10 @@ import type { OAuthGrant } from '$features/users/models'; import type { ProblemDetails } from '@exceptionless/fetchclient'; import type { CreateQueryResult } from '@tanstack/svelte-query'; -import DateTime from '$comp/formatters/date-time.svelte'; import { getSharedTableOptions, type TableMemoryPagingParameters } from '$features/shared/table.svelte'; import OAuthGrantAccessCell from '$features/users/components/oauth-grants/table/oauth-grant-access-cell.svelte'; import OAuthGrantActionsCell from '$features/users/components/oauth-grants/table/oauth-grant-actions-cell.svelte'; import OAuthGrantApplicationCell from '$features/users/components/oauth-grants/table/oauth-grant-application-cell.svelte'; -import OAuthGrantOrganizationsCell from '$features/users/components/oauth-grants/table/oauth-grant-organizations-cell.svelte'; import { type ColumnDef, renderComponent, type StockFeatures } from '@tanstack/svelte-table'; export function getColumns(organizationNamesById: ReadonlyMap): ColumnDef[] { @@ -19,37 +17,17 @@ export function getColumns(organizationNamesById: ReadonlyMap): enableSorting: false, header: 'Application', meta: { - class: 'w-72' + class: 'w-[22rem] min-w-[14rem] max-w-none whitespace-normal align-top' } }, { accessorKey: 'resources', - cell: (info) => renderComponent(OAuthGrantAccessCell, { grant: info.row.original }), - enableHiding: true, + cell: (info) => renderComponent(OAuthGrantAccessCell, { grant: info.row.original, organizationNamesById }), + enableHiding: false, enableSorting: false, header: 'Access', meta: { - class: 'w-96' - } - }, - { - accessorKey: 'organization_ids', - cell: (info) => renderComponent(OAuthGrantOrganizationsCell, { grant: info.row.original, organizationNamesById }), - enableHiding: true, - enableSorting: false, - header: 'Organizations', - meta: { - class: 'w-64' - } - }, - { - accessorKey: 'updated_utc', - cell: (info) => renderComponent(DateTime, { value: info.getValue() }), - enableHiding: true, - enableSorting: false, - header: 'Updated', - meta: { - class: 'w-48 whitespace-nowrap' + class: 'w-auto max-w-none whitespace-normal align-top' } }, { @@ -59,7 +37,7 @@ export function getColumns(organizationNamesById: ReadonlyMap): header: '', id: 'actions', meta: { - class: 'w-12 min-w-12 max-w-12 text-right' + class: 'w-12 min-w-12 max-w-12 text-right align-top' } } ]; @@ -73,7 +51,7 @@ export function getTableOptions( getOrganizationNamesById: () => ReadonlyMap ) { return getSharedTableOptions({ - columnPersistenceKey: 'oauth-grants', + columnPersistenceKey: 'oauth-grants-compact', get columns() { return getColumns(getOrganizationNamesById()); }, diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/applications/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/applications/+page.svelte index e53ac8f30..4d0e44c4a 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/applications/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/applications/+page.svelte @@ -1,7 +1,6 @@ -
-
- {#each grant.resources as resource (resource.resource)} -
-
{formatResource(resource.resource)}
-
- {#each resource.scopes as scope (scope)} - {formatScope(scope)} - {/each} + + + {#snippet child({ props })} + + {/snippet} + + +
+ {#each grant.resources as resource (resource.resource)} +
+
{formatResource(resource.resource)}
+
+ {#each resource.scopes as scope (scope)} + {formatScope(scope)} + {/each} +
-
- {/each} -
- -
-
Organizations
- {#if grant.organization_ids.length > 0} -
- {#each grant.organization_ids as organizationId (organizationId)} - {formatOrganization(organizationId)} - {/each} -
- {:else} - - - {/if} -
-
+ {/each} +
+ + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-application-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-application-cell.svelte index ebada5f4f..b7308acfe 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-application-cell.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-application-cell.svelte @@ -1,7 +1,7 @@ -{#if grant.organization_ids.length > 0} -
- {#each grant.organization_ids as organizationId (organizationId)} - {formatOrganization(organizationId)} - {/each} -
+{#if organizations.length > 0} + + + {#snippet child({ props })} + + {/snippet} + + +
+ {#each organizations as organization (organization.id)} +
{organization.name}
+ {/each} +
+
+
{:else} - {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/options.svelte.ts index b7067c67a..454c7c78a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/options.svelte.ts @@ -6,6 +6,7 @@ import { getSharedTableOptions, type TableMemoryPagingParameters } from '$featur import OAuthGrantAccessCell from '$features/users/components/oauth-grants/table/oauth-grant-access-cell.svelte'; import OAuthGrantActionsCell from '$features/users/components/oauth-grants/table/oauth-grant-actions-cell.svelte'; import OAuthGrantApplicationCell from '$features/users/components/oauth-grants/table/oauth-grant-application-cell.svelte'; +import OAuthGrantOrganizationsCell from '$features/users/components/oauth-grants/table/oauth-grant-organizations-cell.svelte'; import { type ColumnDef, renderComponent, type StockFeatures } from '@tanstack/svelte-table'; export function getColumns(organizationNamesById: ReadonlyMap): ColumnDef[] { @@ -17,17 +18,27 @@ export function getColumns(organizationNamesById: ReadonlyMap): enableSorting: false, header: 'Application', meta: { - class: 'w-[22rem] min-w-[14rem] max-w-none whitespace-normal align-top' + class: 'w-[40%] max-w-none whitespace-normal' } }, { accessorKey: 'resources', - cell: (info) => renderComponent(OAuthGrantAccessCell, { grant: info.row.original, organizationNamesById }), + cell: (info) => renderComponent(OAuthGrantAccessCell, { grant: info.row.original }), enableHiding: false, enableSorting: false, header: 'Access', meta: { - class: 'w-auto max-w-none whitespace-normal align-top' + class: 'w-40 max-w-none whitespace-normal' + } + }, + { + accessorKey: 'organization_ids', + cell: (info) => renderComponent(OAuthGrantOrganizationsCell, { grant: info.row.original, organizationNamesById }), + enableHiding: false, + enableSorting: false, + header: 'Organizations', + meta: { + class: 'w-56 max-w-none whitespace-normal' } }, { @@ -37,7 +48,7 @@ export function getColumns(organizationNamesById: ReadonlyMap): header: '', id: 'actions', meta: { - class: 'w-12 min-w-12 max-w-12 text-right align-top' + class: 'w-12 min-w-12 max-w-12 text-right' } } ]; From 219baf6552d06c864b3fc4d5a53c9f38ad0d2225 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 29 Jun 2026 09:51:31 -0500 Subject: [PATCH 21/23] Fix Copilot OAuth consent flow --- .../Services/OAuthService.cs | 13 +++++-- .../(app)/account/ai-tools/+page.svelte | 8 ++--- .../(auth)/oauth/authorize/+page.svelte | 4 +-- .../Controllers/OAuthControllerTests.cs | 35 +++++++++++++++++++ 4 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/Exceptionless.Core/Services/OAuthService.cs b/src/Exceptionless.Core/Services/OAuthService.cs index c25d291ad..35a5be337 100644 --- a/src/Exceptionless.Core/Services/OAuthService.cs +++ b/src/Exceptionless.Core/Services/OAuthService.cs @@ -300,7 +300,7 @@ private static OAuthClientOptions MapClient(OAuthApplication application) private static string NormalizeClientName(string? clientName, string clientId) { - string name = String.IsNullOrWhiteSpace(clientName) ? clientId : clientName.Trim(); + string name = String.IsNullOrWhiteSpace(clientName) ? "OAuth Application" : clientName.Trim(); return name.Length <= 200 ? name : name[..200]; } @@ -685,7 +685,7 @@ private static bool IsRedirectUriAllowed(string? redirectUri, IReadOnlyCollectio if (!registeredUri.IsDefaultPort && registeredUri.Port != requestedUri.Port) continue; - if (String.Equals(registeredUri.Host, requestedUri.Host, StringComparison.OrdinalIgnoreCase) + if (AreEquivalentLoopbackHosts(registeredUri, requestedUri) && String.Equals(registeredUri.AbsolutePath, requestedUri.AbsolutePath, StringComparison.Ordinal) && String.Equals(registeredUri.Query, requestedUri.Query, StringComparison.Ordinal)) return true; @@ -700,6 +700,15 @@ private static bool IsLoopbackHttpRedirectUri(Uri uri) && (uri.IsLoopback || String.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase)); } + private static bool AreEquivalentLoopbackHosts(Uri registeredUri, Uri requestedUri) + { + if (registeredUri.IsLoopback && requestedUri.IsLoopback) + return true; + + return String.Equals(registeredUri.Host, "localhost", StringComparison.OrdinalIgnoreCase) + && String.Equals(requestedUri.Host, "localhost", StringComparison.OrdinalIgnoreCase); + } + public static string CreateCodeChallenge(string verifier) { byte[] bytes = SHA256.HashData(Encoding.ASCII.GetBytes(verifier)); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte index 4bcb43e02..bbdce83e5 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte @@ -51,9 +51,9 @@ ] }, { - description: 'Use Codex CLI with the hosted Exceptionless MCP server.', + description: 'Use Codex with the hosted Exceptionless MCP server.', id: 'codex', - name: 'Codex CLI', + name: 'Codex', steps: [ { code: `codex mcp add exceptionless --url ${mcpEndpoint}`, @@ -70,9 +70,9 @@ ] }, { - description: 'Use GitHub Copilot CLI with the hosted Exceptionless MCP server.', + description: 'Use Copilot with the hosted Exceptionless MCP server.', id: 'github-copilot', - name: 'GitHub Copilot CLI', + name: 'Copilot', steps: [ { code: `copilot mcp add --transport http exceptionless ${mcpEndpoint}`, diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte index 6d33638d6..dbc4fc10f 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte @@ -66,7 +66,7 @@ const redirectUri = $derived(page.url.searchParams.get('redirect_uri') ?? 'Unknown redirect URI'); const resource = $derived(page.url.searchParams.get('resource') ?? 'Unknown resource'); const applicationClientId = $derived(consentDetails?.client_id ?? clientId); - const applicationDisplayName = $derived(consentDetails?.client_name || applicationClientId); + const applicationDisplayName = $derived(consentDetails?.client_name || 'Unknown application'); const displayRedirectUri = $derived(consentDetails?.redirect_uri ?? redirectUri); const displayResource = $derived(consentDetails?.resource ?? resource); const accountDisplayName = $derived(meQuery.data?.full_name || meQuery.data?.email_address || 'Unknown account'); @@ -371,7 +371,7 @@

Loading application...

{:else}

{applicationDisplayName}

- {#if applicationClientId !== applicationDisplayName} + {#if consentDetails?.client_name && applicationClientId !== applicationDisplayName}

{applicationClientId}

{/if} {/if} diff --git a/tests/Exceptionless.Tests/Controllers/OAuthControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OAuthControllerTests.cs index 5f41775d8..f7ad45277 100644 --- a/tests/Exceptionless.Tests/Controllers/OAuthControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OAuthControllerTests.cs @@ -148,6 +148,41 @@ public async Task RegisterAsync_WithoutScope_DefaultsToReadOnlyScopes() Assert.Contains(AuthorizationRoles.OfflineAccess, application.Scopes); } + [Fact] + public async Task GetAuthorizeConsentAsync_DynamicClientAllowsEquivalentLoopbackHostWithDifferentPort_ReturnsClientDetails() + { + using var client = CreateHttpClient(); + + var registrationResponse = await client.PostAsJsonAsync("oauth/register", new OAuthClientRegistrationRequest + { + ClientName = "Copilot", + RedirectUris = ["http://localhost"], + GrantTypes = [OAuthGrantTypes.AuthorizationCode, OAuthGrantTypes.RefreshToken], + ResponseTypes = ["code"], + TokenEndpointAuthMethod = "none" + }, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Created, registrationResponse.StatusCode); + var registration = await DeserializeResponseAsync(registrationResponse); + Assert.NotNull(registration); + + using var request = CreateAuthorizeJsonRequest( + PkceVerifier, + "http://127.0.0.1:63952/", + clientId: registration.ClientId, + organizationIds: []); + request.RequestUri = new Uri("oauth/authorize/consent", UriKind.Relative); + + var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var consent = await DeserializeResponseAsync(response); + Assert.NotNull(consent); + Assert.Equal(registration.ClientId, consent.ClientId); + Assert.Equal("Copilot", consent.ClientName); + Assert.Equal("http://127.0.0.1:63952/", consent.RedirectUri); + } + [Fact] public async Task RegisterAsync_TooManyAttempts_ReturnsTooManyRequests() { From 2259ece7c12f1f9b1c2928e19fe3290af9280ac7 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 29 Jun 2026 13:28:42 -0500 Subject: [PATCH 22/23] Update Codex MCP setup instructions --- .../src/routes/(app)/account/ai-tools/+page.svelte | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte index bbdce83e5..afadaf42a 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte @@ -57,15 +57,9 @@ steps: [ { code: `codex mcp add exceptionless --url ${mcpEndpoint}`, - description: 'Register the streamable HTTP MCP server with Codex.', + description: 'Register the streamable HTTP MCP server with Codex and approve access when prompted.', language: 'shellscript', - title: 'Add the server' - }, - { - code: 'codex mcp login exceptionless', - description: 'Start the OAuth browser flow and approve access.', - language: 'shellscript', - title: 'Authenticate' + title: 'Add and authenticate' } ] }, From 3622ad8aaa94268a45bff9d568b88ab6eec5f3e2 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 29 Jun 2026 13:44:14 -0500 Subject: [PATCH 23/23] Use dev MCP server name on dev app --- .../routes/(app)/account/ai-tools/+page.svelte | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte index afadaf42a..981c5a4a2 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte @@ -22,14 +22,20 @@ }; let mcpEndpoint = $state('/mcp'); + let mcpServerName = $state('exceptionless'); let selectedToolId = $state('claude'); $effect(() => { if (browser) { mcpEndpoint = `${window.location.origin}/mcp`; + mcpServerName = getMcpServerName(window.location.hostname); } }); + function getMcpServerName(hostname: string): string { + return hostname.toLowerCase() === 'dev-app.exceptionless.io' ? 'exceptionless-dev' : 'exceptionless'; + } + const aiTools = $derived([ { description: 'Use Claude Code with the hosted Exceptionless MCP server.', @@ -37,13 +43,13 @@ name: 'Claude Code', steps: [ { - code: `claude mcp add --transport http exceptionless ${mcpEndpoint}`, + code: `claude mcp add --transport http ${mcpServerName} ${mcpEndpoint}`, description: 'Add the hosted MCP server to Claude Code.', language: 'shellscript', title: 'Add the server' }, { - code: 'claude mcp login exceptionless', + code: `claude mcp login ${mcpServerName}`, description: 'Start the OAuth browser flow and approve access.', language: 'shellscript', title: 'Authenticate' @@ -56,7 +62,7 @@ name: 'Codex', steps: [ { - code: `codex mcp add exceptionless --url ${mcpEndpoint}`, + code: `codex mcp add ${mcpServerName} --url ${mcpEndpoint}`, description: 'Register the streamable HTTP MCP server with Codex and approve access when prompted.', language: 'shellscript', title: 'Add and authenticate' @@ -69,7 +75,7 @@ name: 'Copilot', steps: [ { - code: `copilot mcp add --transport http exceptionless ${mcpEndpoint}`, + code: `copilot mcp add --transport http ${mcpServerName} ${mcpEndpoint}`, description: 'Register the hosted HTTP MCP server with Copilot.', language: 'shellscript', title: 'Add the server' @@ -88,13 +94,13 @@ name: 'OpenCode', steps: [ { - code: `opencode mcp add exceptionless --url ${mcpEndpoint}`, + code: `opencode mcp add ${mcpServerName} --url ${mcpEndpoint}`, description: 'Register the hosted HTTP MCP server with OpenCode.', language: 'shellscript', title: 'Add the server' }, { - code: 'opencode mcp auth exceptionless', + code: `opencode mcp auth ${mcpServerName}`, description: 'Start the OAuth browser flow and approve access.', language: 'shellscript', title: 'Authenticate'