Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a2a0d92
Add REST OAuth resource scopes
ejsmith Jun 27, 2026
318b9bf
Stabilize MCP grouped trend test
ejsmith Jun 27, 2026
1438ac4
Harden legacy user description test
ejsmith Jun 27, 2026
4cc0bdc
Add OAuth grant management
ejsmith Jun 27, 2026
a7374cf
Allow OAuth consent scope reduction
ejsmith Jun 27, 2026
d691ede
Tighten OAuth consent and token grants
ejsmith Jun 27, 2026
75027ea
Revoke OAuth grant family on refresh replay
ejsmith Jun 27, 2026
f01b90a
Cache OAuth client checks in bearer auth
ejsmith Jun 27, 2026
3c5a68a
Validate refresh scopes against OAuth clients
ejsmith Jun 27, 2026
82b4714
Store OAuth bearer tokens separately
ejsmith Jun 28, 2026
4ce1b39
Merge remote-tracking branch 'origin/main' into feature/oauth-rest-re…
ejsmith Jun 28, 2026
465e12c
Fix client lint export order
ejsmith Jun 28, 2026
a8f8211
Update OAuth grant OpenAPI snapshot
ejsmith Jun 28, 2026
b75b349
Improve OAuth grant UI and MCP consent
ejsmith Jun 29, 2026
0b74944
Show required OAuth scopes without toggles
ejsmith Jun 29, 2026
0b1a8e5
Fix table helper for locked TanStack version
ejsmith Jun 29, 2026
bb712cc
Use standalone Copilot CLI instructions
ejsmith Jun 29, 2026
2020f09
Fix MCP output schema optional fields
ejsmith Jun 29, 2026
4c249b4
Default OAuth consent organizations to selected
ejsmith Jun 29, 2026
4f0a058
Compact OAuth applications table
ejsmith Jun 29, 2026
5a0f956
Refine OAuth applications table layout
ejsmith Jun 29, 2026
219baf6
Fix Copilot OAuth consent flow
ejsmith Jun 29, 2026
2259ece
Update Codex MCP setup instructions
ejsmith Jun 29, 2026
3622ad8
Use dev MCP server name on dev app
ejsmith Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/Exceptionless.Core/Authorization/AuthorizationRoles.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions src/Exceptionless.Core/Bootstrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO
services.AddSingleton<MigrationIndex>(s => s.GetRequiredService<ExceptionlessElasticConfiguration>().Migrations);
services.AddSingleton<IOrganizationRepository, OrganizationRepository>();
services.AddSingleton<IOAuthApplicationRepository, OAuthApplicationRepository>();
services.AddSingleton<IOAuthTokenRepository, OAuthTokenRepository>();
services.AddSingleton<IProjectRepository, ProjectRepository>();
services.AddSingleton<IUserRepository, UserRepository>();
services.AddSingleton<IWebHookRepository, WebHookRepository>();
Expand Down
47 changes: 36 additions & 11 deletions src/Exceptionless.Core/Extensions/IdentityUtils.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Security.Claims;
using System.Security.Claims;
using Exceptionless.Core.Authorization;
using Exceptionless.Core.Models;
using IIdentity = System.Security.Principal.IIdentity;
Expand Down Expand Up @@ -50,10 +50,17 @@ public static ClaimsIdentity ToIdentity(this Token token)

public static ClaimsIdentity ToIdentity(this User user, Token? token = null)
{
return user.ToIdentity(token, null);
}

public static ClaimsIdentity ToIdentity(this User user, Token? token, IReadOnlyCollection<string>? organizationIds)
{
organizationIds ??= user.OrganizationIds.ToArray();

var claims = new List<Claim>(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)
Expand All @@ -62,15 +69,9 @@ public static ClaimsIdentity ToIdentity(this User user, Token? token = null)

if (!String.IsNullOrEmpty(token.DefaultProjectId))
claims.Add(new Claim(DefaultProjectIdClaim, token.DefaultProjectId));

if (!String.IsNullOrEmpty(token.OAuthClientId))
claims.Add(new Claim(OAuthClientIdClaim, token.OAuthClientId));

if (!String.IsNullOrEmpty(token.OAuthResource))
claims.Add(new Claim(OAuthResourceClaim, token.OAuthResource));
}

if (token is { OAuthType: OAuthTokenType.Access } && token.Scopes.Count > 0)
if (token is { Type: TokenType.Access } && token.Scopes.Count > 0)
{
foreach (string scope in token.Scopes)
claims.Add(new Claim(ClaimTypes.Role, scope));
Expand Down Expand Up @@ -98,6 +99,31 @@ public static ClaimsIdentity ToIdentity(this User user, Token? token = null)
return new ClaimsIdentity(claims, authenticationType);
}

public static ClaimsIdentity ToIdentity(this User user, OAuthToken token, IReadOnlyCollection<string>? organizationIds = null)
{
organizationIds ??= user.GetActiveOAuthOrganizationIds(token);

var claims = new List<Claim>(6 + token.Scopes.Count) {
new(ClaimTypes.Name, user.EmailAddress),
new(ClaimTypes.NameIdentifier, user.Id),
new(OrganizationIdsClaim, String.Join(",", organizationIds)),
new(LoggedInUsersTokenId, token.Id),
new(OAuthClientIdClaim, token.ClientId),
new(OAuthResourceClaim, token.Resource)
};

foreach (string scope in token.Scopes)
claims.Add(new Claim(ClaimTypes.Role, scope));

return new ClaimsIdentity(claims, TokenAuthenticationType);
}

public static IReadOnlyCollection<string> GetActiveOAuthOrganizationIds(this User user, OAuthToken token)
{
var userOrganizationIds = user.OrganizationIds.ToHashSet(StringComparer.Ordinal);
return token.OrganizationIds.Where(userOrganizationIds.Contains).Distinct(StringComparer.Ordinal).ToArray();
}

public static bool IsAuthenticated(this ClaimsPrincipal principal)
{
return principal.Identity is not null && principal.Identity.IsAuthenticated;
Expand Down Expand Up @@ -140,9 +166,8 @@ public static bool IsUserAuthType(this ClaimsPrincipal principal)
}

/// <summary>
/// Gets the token id that authenticated the current user. If null, user logged in via oauth.
/// Gets the token id that authenticated the current user.
/// </summary>
/// <param name="principal"></param>
public static string? GetLoggedInUsersTokenId(this ClaimsPrincipal principal)
{
return IsUserAuthType(principal) ? GetClaimValue(principal, LoggedInUsersTokenId) : null;
Expand Down
70 changes: 70 additions & 0 deletions src/Exceptionless.Core/Models/OAuthToken.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System.ComponentModel.DataAnnotations;
using Exceptionless.Core.Attributes;
using Foundatio.Repositories.Models;

namespace Exceptionless.Core.Models;

public class OAuthToken : IIdentity, IHaveDates, IValidatableObject
{
[Required]
[ObjectId]
public string Id { get; set; } = null!;

[Required]
[ObjectId]
public string UserId { get; set; } = null!;

[Required]
[MaxLength(2048)]
public string ClientId { get; set; } = null!;

[Required]
[MaxLength(100)]
public string GrantId { get; set; } = null!;

[Required]
[MaxLength(2048)]
public string Resource { get; set; } = null!;

[Required]
[MaxLength(100)]
public string AccessTokenHash { get; set; } = null!;

[MaxLength(100)]
public string? RefreshTokenHash { get; set; }

public DateTime? ExpiresUtc { get; set; }
public DateTime? RefreshExpiresUtc { get; set; }
public HashSet<string> OrganizationIds { get; set; } = new(StringComparer.Ordinal);
public HashSet<string> Scopes { get; set; } = new(StringComparer.Ordinal);
public bool IsDisabled { get; set; }
public bool IsSuspended { get; set; }

[Required]
[ObjectId]
public string CreatedBy { get; set; } = null!;

public DateTime CreatedUtc { get; set; }
public DateTime UpdatedUtc { get; set; }

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (CreatedUtc == DateTime.MinValue)
yield return new ValidationResult("Please specify a valid created date.", [nameof(CreatedUtc)]);

if (UpdatedUtc == DateTime.MinValue)
yield return new ValidationResult("Please specify a valid updated date.", [nameof(UpdatedUtc)]);

if (Scopes.Count == 0)
yield return new ValidationResult("OAuth tokens must specify at least one scope.", [nameof(Scopes)]);

foreach (string _ in Scopes.Where(String.IsNullOrWhiteSpace))
yield return new ValidationResult("OAuth scope cannot be empty.", [nameof(Scopes)]);

if (OrganizationIds.Count == 0)
yield return new ValidationResult("OAuth tokens must specify at least one organization id.", [nameof(OrganizationIds)]);

foreach (string _ in OrganizationIds.Where(String.IsNullOrWhiteSpace))
yield return new ValidationResult("OAuth organization id cannot be empty.", [nameof(OrganizationIds)]);
}
}
14 changes: 2 additions & 12 deletions src/Exceptionless.Core/Models/Token.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using Exceptionless.Core.Attributes;
using Foundatio.Repositories.Models;

Expand All @@ -17,7 +17,7 @@ public class Token : IOwnedByOrganizationAndProjectWithIdentity, IHaveDates, IVa
public string OrganizationId { get; set; } = null!;

/// <summary>
/// Null for org-scoped or user-scoped tokens.
/// Null for organization-scoped or user-scoped tokens.
/// Cannot be set together with UserId.
/// </summary>
[ObjectId]
Expand All @@ -36,10 +36,6 @@ public class Token : IOwnedByOrganizationAndProjectWithIdentity, IHaveDates, IVa
public string? DefaultProjectId { get; set; }
public string? Refresh { get; set; }
public TokenType Type { get; set; }
public OAuthTokenType OAuthType { get; set; }
public string? OAuthClientId { get; set; }
public string? OAuthResource { get; set; }
public DateTime? OAuthRefreshExpiresUtc { get; set; }
public HashSet<string> Scopes { get; set; } = new();
public DateTime? ExpiresUtc { get; set; }
public string? Notes { get; set; }
Expand Down Expand Up @@ -91,9 +87,3 @@ public enum TokenType
Authentication,
Access
}

public enum OAuthTokenType
{
None,
Access
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ ILoggerFactory loggerFactory
AddIndex(Migrations = new MigrationIndex(this, _appOptions.ElasticsearchOptions.ScopePrefix + "migrations", appOptions.ElasticsearchOptions.NumberOfReplicas));
AddIndex(Organizations = new OrganizationIndex(this));
AddIndex(OAuthApplications = new OAuthApplicationIndex(this));
AddIndex(OAuthTokens = new OAuthTokenIndex(this));
AddIndex(Projects = new ProjectIndex(this));
AddIndex(SavedViews = new SavedViewIndex(this));
AddIndex(Tokens = new TokenIndex(this));
Expand Down Expand Up @@ -73,6 +74,7 @@ public override void ConfigureGlobalQueryBuilders(ElasticQueryBuilder builder)
public MigrationIndex Migrations { get; }
public OrganizationIndex Organizations { get; }
public OAuthApplicationIndex OAuthApplications { get; }
public OAuthTokenIndex OAuthTokens { get; }
public ProjectIndex Projects { get; }
public SavedViewIndex SavedViews { get; }
public TokenIndex Tokens { get; }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using Elastic.Clients.Elasticsearch.IndexManagement;
using Elastic.Clients.Elasticsearch.Mapping;
using Exceptionless.Core.Models;
using Foundatio.Repositories.Elasticsearch.Configuration;
using Foundatio.Repositories.Elasticsearch.Extensions;

namespace Exceptionless.Core.Repositories.Configuration;

public sealed class OAuthTokenIndex : VersionedIndex<OAuthToken>
{
private readonly ExceptionlessElasticConfiguration _configuration;

public OAuthTokenIndex(ExceptionlessElasticConfiguration configuration) : base(configuration, configuration.Options.ScopePrefix + "oauth-tokens", 1)
{
_configuration = configuration;
}

public override void ConfigureIndexMapping(TypeMappingDescriptor<OAuthToken> map)
{
map
.Dynamic(DynamicMapping.False)
.Properties(p => p
.SetupDefaults()
.Keyword(e => e.UserId)
.Keyword(e => e.ClientId)
.Keyword(e => e.GrantId)
.Keyword(e => e.Resource)
.Keyword(e => e.AccessTokenHash)
.Keyword(e => e.RefreshTokenHash)
.Keyword(e => e.OrganizationIds)
.Keyword(e => e.Scopes)
.Keyword(e => e.CreatedBy)
.Date(e => e.ExpiresUtc)
.Date(e => e.RefreshExpiresUtc)
.Boolean(e => e.IsDisabled)
.Boolean(e => e.IsSuspended));
}

public override void ConfigureIndex(CreateIndexRequestDescriptor idx)
{
base.ConfigureIndex(idx);
idx.Settings(s => s
.NumberOfShards(_configuration.Options.NumberOfShards)
.NumberOfReplicas(_configuration.Options.NumberOfReplicas)
.Priority(5));
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Elastic.Clients.Elasticsearch.IndexManagement;
using Elastic.Clients.Elasticsearch.IndexManagement;
using Elastic.Clients.Elasticsearch.Mapping;
using Foundatio.Repositories.Elasticsearch.Configuration;
using Foundatio.Repositories.Elasticsearch.Extensions;
Expand All @@ -10,7 +10,7 @@ public sealed class TokenIndex : VersionedIndex<Models.Token>
internal const string KEYWORD_LOWERCASE_ANALYZER = "keyword_lowercase";
private readonly ExceptionlessElasticConfiguration _configuration;

public TokenIndex(ExceptionlessElasticConfiguration configuration) : base(configuration, configuration.Options.ScopePrefix + "tokens", 1)
public TokenIndex(ExceptionlessElasticConfiguration configuration) : base(configuration, configuration.Options.ScopePrefix + "tokens", 3)
{
_configuration = configuration;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Exceptionless.Core.Models;
using Foundatio.Repositories;
using Foundatio.Repositories.Models;

namespace Exceptionless.Core.Repositories;

public interface IOAuthTokenRepository : ISearchableRepository<OAuthToken>
{
Task<FindResults<OAuthToken>> GetByAccessTokenHashAsync(string accessTokenHash, CommandOptionsDescriptor<OAuthToken>? options = null);
Task<FindResults<OAuthToken>> GetByRefreshTokenHashAsync(string refreshTokenHash, CommandOptionsDescriptor<OAuthToken>? options = null);
Task<FindResults<OAuthToken>> GetByGrantIdAsync(string grantId, CommandOptionsDescriptor<OAuthToken>? options = null);
Task<FindResults<OAuthToken>> GetByUserIdAsync(string userId, CommandOptionsDescriptor<OAuthToken>? options = null);
Task<FindResults<OAuthToken>> GetByUserIdAndClientIdAsync(string userId, string clientId, CommandOptionsDescriptor<OAuthToken>? options = null);
Task<long> RemoveAllByUserIdAsync(string userId, CommandOptionsDescriptor<OAuthToken>? options = null);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Exceptionless.Core.Models;
using Exceptionless.Core.Models;
using Foundatio.Repositories;
using Foundatio.Repositories.Models;

Expand Down
48 changes: 48 additions & 0 deletions src/Exceptionless.Core/Repositories/OAuthTokenRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using Exceptionless.Core.Models;
using Exceptionless.Core.Repositories.Configuration;
using Exceptionless.Core.Validation;
using Foundatio.Repositories;
using Foundatio.Repositories.Models;

namespace Exceptionless.Core.Repositories;

public class OAuthTokenRepository : RepositoryBase<OAuthToken>, IOAuthTokenRepository
{
public OAuthTokenRepository(ExceptionlessElasticConfiguration configuration, MiniValidationValidator validator, AppOptions options)
: base(configuration.OAuthTokens, validator, options)
{
DefaultConsistency = Consistency.Immediate;
}

public Task<FindResults<OAuthToken>> GetByAccessTokenHashAsync(string accessTokenHash, CommandOptionsDescriptor<OAuthToken>? options = null)
{
return FindAsync(q => q.FieldEquals(t => t.AccessTokenHash, accessTokenHash), options);
}

public Task<FindResults<OAuthToken>> GetByRefreshTokenHashAsync(string refreshTokenHash, CommandOptionsDescriptor<OAuthToken>? options = null)
{
return FindAsync(q => q
.FieldEquals(t => t.RefreshTokenHash, refreshTokenHash)
.SortDescending(t => t.CreatedUtc), options);
}

public Task<FindResults<OAuthToken>> GetByGrantIdAsync(string grantId, CommandOptionsDescriptor<OAuthToken>? options = null)
{
return FindAsync(q => q.FieldEquals(t => t.GrantId, grantId).SortDescending(t => t.UpdatedUtc), options);
}

public Task<FindResults<OAuthToken>> GetByUserIdAsync(string userId, CommandOptionsDescriptor<OAuthToken>? options = null)
{
return FindAsync(q => q.FieldEquals(t => t.UserId, userId).SortDescending(t => t.UpdatedUtc), options);
}

public Task<FindResults<OAuthToken>> GetByUserIdAndClientIdAsync(string userId, string clientId, CommandOptionsDescriptor<OAuthToken>? options = null)
{
return FindAsync(q => q.FieldEquals(t => t.UserId, userId).FieldEquals(t => t.ClientId, clientId).SortDescending(t => t.UpdatedUtc), options);
}

public Task<long> RemoveAllByUserIdAsync(string userId, CommandOptionsDescriptor<OAuthToken>? options = null)
{
return RemoveAllAsync(q => q.FieldEquals(t => t.UserId, userId), options);
}
}
Loading