diff --git a/src/Exceptionless.Core/Authorization/AuthorizationRoles.cs b/src/Exceptionless.Core/Authorization/AuthorizationRoles.cs index 2d950468f9..546b06e864 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/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index 1ef0908dd0..9cfb19de39 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -140,6 +140,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(s => s.GetRequiredService().Migrations); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Exceptionless.Core/Extensions/IdentityUtils.cs b/src/Exceptionless.Core/Extensions/IdentityUtils.cs index 3b76d0dec0..2d4274b1ad 100644 --- a/src/Exceptionless.Core/Extensions/IdentityUtils.cs +++ b/src/Exceptionless.Core/Extensions/IdentityUtils.cs @@ -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; @@ -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? organizationIds) + { + organizationIds ??= user.OrganizationIds.ToArray(); + 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) @@ -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)); @@ -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? organizationIds = null) + { + organizationIds ??= user.GetActiveOAuthOrganizationIds(token); + + var claims = new List(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 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; @@ -140,9 +166,8 @@ public static bool IsUserAuthType(this ClaimsPrincipal principal) } /// - /// 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. /// - /// public static string? GetLoggedInUsersTokenId(this ClaimsPrincipal principal) { return IsUserAuthType(principal) ? GetClaimValue(principal, LoggedInUsersTokenId) : null; diff --git a/src/Exceptionless.Core/Models/OAuthToken.cs b/src/Exceptionless.Core/Models/OAuthToken.cs new file mode 100644 index 0000000000..466efeea70 --- /dev/null +++ b/src/Exceptionless.Core/Models/OAuthToken.cs @@ -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 OrganizationIds { get; set; } = new(StringComparer.Ordinal); + public HashSet 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 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)]); + } +} diff --git a/src/Exceptionless.Core/Models/Token.cs b/src/Exceptionless.Core/Models/Token.cs index 5e19480b26..d2592769f2 100644 --- a/src/Exceptionless.Core/Models/Token.cs +++ b/src/Exceptionless.Core/Models/Token.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Exceptionless.Core.Attributes; using Foundatio.Repositories.Models; @@ -17,7 +17,7 @@ public class Token : IOwnedByOrganizationAndProjectWithIdentity, IHaveDates, IVa public string OrganizationId { get; set; } = null!; /// - /// Null for org-scoped or user-scoped tokens. + /// Null for organization-scoped or user-scoped tokens. /// Cannot be set together with UserId. /// [ObjectId] @@ -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 Scopes { get; set; } = new(); public DateTime? ExpiresUtc { get; set; } public string? Notes { get; set; } @@ -91,9 +87,3 @@ public enum TokenType Authentication, Access } - -public enum OAuthTokenType -{ - None, - Access -} diff --git a/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs b/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs index 14ca598ac6..7fd742b21b 100644 --- a/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs +++ b/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs @@ -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)); @@ -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; } diff --git a/src/Exceptionless.Core/Repositories/Configuration/Indexes/OAuthTokenIndex.cs b/src/Exceptionless.Core/Repositories/Configuration/Indexes/OAuthTokenIndex.cs new file mode 100644 index 0000000000..6bb339f5c3 --- /dev/null +++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/OAuthTokenIndex.cs @@ -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 +{ + private readonly ExceptionlessElasticConfiguration _configuration; + + public OAuthTokenIndex(ExceptionlessElasticConfiguration configuration) : base(configuration, configuration.Options.ScopePrefix + "oauth-tokens", 1) + { + _configuration = configuration; + } + + public override void ConfigureIndexMapping(TypeMappingDescriptor 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)); + } +} diff --git a/src/Exceptionless.Core/Repositories/Configuration/Indexes/TokenIndex.cs b/src/Exceptionless.Core/Repositories/Configuration/Indexes/TokenIndex.cs index f512d73bd7..950661f1f9 100644 --- a/src/Exceptionless.Core/Repositories/Configuration/Indexes/TokenIndex.cs +++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/TokenIndex.cs @@ -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; @@ -10,7 +10,7 @@ public sealed class TokenIndex : VersionedIndex 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; } diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IOAuthTokenRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IOAuthTokenRepository.cs new file mode 100644 index 0000000000..2bbf817476 --- /dev/null +++ b/src/Exceptionless.Core/Repositories/Interfaces/IOAuthTokenRepository.cs @@ -0,0 +1,15 @@ +using Exceptionless.Core.Models; +using Foundatio.Repositories; +using Foundatio.Repositories.Models; + +namespace Exceptionless.Core.Repositories; + +public interface IOAuthTokenRepository : ISearchableRepository +{ + Task> GetByAccessTokenHashAsync(string accessTokenHash, CommandOptionsDescriptor? options = null); + Task> GetByRefreshTokenHashAsync(string refreshTokenHash, CommandOptionsDescriptor? options = null); + Task> GetByGrantIdAsync(string grantId, CommandOptionsDescriptor? options = null); + Task> GetByUserIdAsync(string userId, CommandOptionsDescriptor? options = null); + Task> GetByUserIdAndClientIdAsync(string userId, string clientId, CommandOptionsDescriptor? options = null); + Task RemoveAllByUserIdAsync(string userId, CommandOptionsDescriptor? options = null); +} diff --git a/src/Exceptionless.Core/Repositories/Interfaces/ITokenRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/ITokenRepository.cs index 6e6c5d99f8..2ef7338f01 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/ITokenRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/ITokenRepository.cs @@ -1,4 +1,4 @@ -using Exceptionless.Core.Models; +using Exceptionless.Core.Models; using Foundatio.Repositories; using Foundatio.Repositories.Models; diff --git a/src/Exceptionless.Core/Repositories/OAuthTokenRepository.cs b/src/Exceptionless.Core/Repositories/OAuthTokenRepository.cs new file mode 100644 index 0000000000..c9994c9ae4 --- /dev/null +++ b/src/Exceptionless.Core/Repositories/OAuthTokenRepository.cs @@ -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, IOAuthTokenRepository +{ + public OAuthTokenRepository(ExceptionlessElasticConfiguration configuration, MiniValidationValidator validator, AppOptions options) + : base(configuration.OAuthTokens, validator, options) + { + DefaultConsistency = Consistency.Immediate; + } + + public Task> GetByAccessTokenHashAsync(string accessTokenHash, CommandOptionsDescriptor? options = null) + { + return FindAsync(q => q.FieldEquals(t => t.AccessTokenHash, accessTokenHash), options); + } + + public Task> GetByRefreshTokenHashAsync(string refreshTokenHash, CommandOptionsDescriptor? options = null) + { + return FindAsync(q => q + .FieldEquals(t => t.RefreshTokenHash, refreshTokenHash) + .SortDescending(t => t.CreatedUtc), options); + } + + public Task> GetByGrantIdAsync(string grantId, CommandOptionsDescriptor? options = null) + { + return FindAsync(q => q.FieldEquals(t => t.GrantId, grantId).SortDescending(t => t.UpdatedUtc), options); + } + + public Task> GetByUserIdAsync(string userId, CommandOptionsDescriptor? options = null) + { + return FindAsync(q => q.FieldEquals(t => t.UserId, userId).SortDescending(t => t.UpdatedUtc), options); + } + + public Task> GetByUserIdAndClientIdAsync(string userId, string clientId, CommandOptionsDescriptor? options = null) + { + return FindAsync(q => q.FieldEquals(t => t.UserId, userId).FieldEquals(t => t.ClientId, clientId).SortDescending(t => t.UpdatedUtc), options); + } + + public Task RemoveAllByUserIdAsync(string userId, CommandOptionsDescriptor? options = null) + { + return RemoveAllAsync(q => q.FieldEquals(t => t.UserId, userId), options); + } +} diff --git a/src/Exceptionless.Core/Services/OAuthService.cs b/src/Exceptionless.Core/Services/OAuthService.cs index 3ebb785c10..35a5be3377 100644 --- a/src/Exceptionless.Core/Services/OAuthService.cs +++ b/src/Exceptionless.Core/Services/OAuthService.cs @@ -14,13 +14,42 @@ namespace Exceptionless.Core.Services; -public class OAuthService(OAuthServerOptions options, ICacheClient cacheClient, ILockProvider lockProvider, IOAuthApplicationRepository oauthApplicationRepository, IOAuthClientMetadataService oauthClientMetadataService, ITokenRepository tokenRepository, TimeProvider timeProvider) +public class OAuthService(OAuthServerOptions options, ICacheClient cacheClient, ILockProvider lockProvider, IOAuthApplicationRepository oauthApplicationRepository, IOAuthClientMetadataService oauthClientMetadataService, IOAuthTokenRepository oauthTokenRepository, IUserRepository userRepository, TimeProvider timeProvider) { public const string CodeChallengeMethod = "S256"; public const int ClientIdLength = 32; public const int PkceCodeChallengeLength = 43; public const int PkceCodeVerifierMinLength = 43; public const int PkceCodeVerifierMaxLength = 128; + public const int OAuthTokenLength = 64; + public static readonly OAuthResourceDefinition McpResource = new("/mcp", + [ + AuthorizationRoles.McpRead, + AuthorizationRoles.ProjectsRead, + AuthorizationRoles.StacksRead, + AuthorizationRoles.StacksWrite, + AuthorizationRoles.EventsRead, + AuthorizationRoles.OfflineAccess + ], + [ + AuthorizationRoles.McpRead, + AuthorizationRoles.OfflineAccess + ]); + + 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 SupportedScopes = [ AuthorizationRoles.McpRead, @@ -36,11 +65,15 @@ public class OAuthService(OAuthServerOptions options, ICacheClient cacheClient, AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead, AuthorizationRoles.StacksRead, - AuthorizationRoles.EventsRead + AuthorizationRoles.EventsRead, + AuthorizationRoles.OfflineAccess ]; private const string AuthorizationCodeCachePrefix = "oauth:code:"; private const string RefreshTokenLockPrefix = "oauth:refresh:"; + private const string AccessTokenClientValidityCachePrefix = "oauth:client-valid:"; + private const int OAuthGrantFamilyPageLimit = 1000; + private static readonly TimeSpan AccessTokenClientValidityCacheLifetime = TimeSpan.FromSeconds(30); private const string ClientMetadataNotes = "Discovered from OAuth client metadata document."; private const string DynamicClientRegistrationNotes = "Registered through OAuth dynamic client registration."; private static readonly Regex CodeChallengeRegex = new("^[A-Za-z0-9_-]{43}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); @@ -99,6 +132,7 @@ public async Task RegisterClientAsync(OAuthClient }; await oauthApplicationRepository.AddAsync(application, o => o.ImmediateConsistency()); + await ClearAccessTokenClientValidityCacheAsync(application.ClientId); return OAuthClientRegistrationResult.Success(new OAuthClientRegistrationResponse { ClientId = application.ClientId, @@ -136,6 +170,30 @@ public async Task RegisterClientAsync(OAuthClient return await GetClientFromMetadataDocumentAsync(clientId); } + public async Task IsAccessTokenClientValidAsync(string? clientId) + { + if (String.IsNullOrWhiteSpace(clientId)) + return false; + + clientId = clientId.Trim(); + string cacheKey = GetAccessTokenClientValidityCacheKey(clientId); + bool? cached = await cacheClient.GetAsync(cacheKey, null); + if (cached.HasValue) + return cached.Value; + + var application = await oauthApplicationRepository.GetByClientIdAsync(clientId); + bool isValid = application is { IsDisabled: false }; + await cacheClient.SetAsync(cacheKey, isValid, AccessTokenClientValidityCacheLifetime); + return isValid; + } + + public Task ClearAccessTokenClientValidityCacheAsync(string? clientId) + { + return String.IsNullOrWhiteSpace(clientId) + ? Task.CompletedTask + : cacheClient.RemoveAsync(GetAccessTokenClientValidityCacheKey(clientId.Trim())); + } + private async Task GetClientFromMetadataDocumentAsync(string clientId) { var metadata = await oauthClientMetadataService.GetClientMetadataAsync(clientId); @@ -143,6 +201,7 @@ public async Task RegisterClientAsync(OAuthClient return null; await oauthApplicationRepository.AddAsync(application, o => o.ImmediateConsistency()); + await ClearAccessTokenClientValidityCacheAsync(application.ClientId); return MapClient(application); } @@ -172,6 +231,7 @@ private async Task RefreshObservedApplicationAsync(OAuthApplic application.UpdatedUtc = timeProvider.GetUtcNow().UtcDateTime; await oauthApplicationRepository.SaveAsync(application, o => o.ImmediateConsistency()); + await ClearAccessTokenClientValidityCacheAsync(application.ClientId); return application; } @@ -240,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]; } @@ -261,7 +321,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 +335,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 +358,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 +388,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 +422,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) @@ -363,73 +430,153 @@ public async Task RefreshAsync(OAuthTokenRequest request) if (String.IsNullOrWhiteSpace(request.RefreshToken) || String.IsNullOrWhiteSpace(request.ClientId)) return OAuthTokenIssueResult.Invalid("invalid_request", "Missing refresh_token or client_id."); - if (await GetClientAsync(request.ClientId) is null) + var client = await GetClientAsync(request.ClientId); + if (client is null) return OAuthTokenIssueResult.Invalid("invalid_client", "Unknown OAuth client."); await using var refreshTokenLock = await lockProvider.TryAcquireAsync(GetRefreshTokenLockKey(request.RefreshToken), TimeSpan.FromSeconds(30), CancellationToken.None); if (refreshTokenLock is null) return OAuthTokenIssueResult.Invalid("invalid_grant", "Refresh token is invalid."); - var results = await tokenRepository.GetByRefreshTokenAsync(request.RefreshToken, o => o.ImmediateConsistency()); + var results = await oauthTokenRepository.GetByRefreshTokenHashAsync(CreateTokenHash(request.RefreshToken), o => o.ImmediateConsistency()); var token = results.Documents.FirstOrDefault(); - if (token is null || token.IsDisabled || token.IsSuspended || token.OAuthType != OAuthTokenType.Access || !String.Equals(token.OAuthClientId, request.ClientId, StringComparison.Ordinal)) + if (token is null || !String.Equals(token.ClientId, request.ClientId, StringComparison.Ordinal)) + return OAuthTokenIssueResult.Invalid("invalid_grant", "Refresh token is invalid."); + + if (token.IsDisabled || token.IsSuspended) + { + await RevokeOAuthGrantFamilyAsync(token); + return OAuthTokenIssueResult.Invalid("invalid_grant", "Refresh token is invalid."); + } + + if (token.OrganizationIds.Count == 0) return OAuthTokenIssueResult.Invalid("invalid_grant", "Refresh token is invalid."); - if (token.OAuthRefreshExpiresUtc.HasValue && token.OAuthRefreshExpiresUtc.Value < timeProvider.GetUtcNow().UtcDateTime) + if (token.RefreshExpiresUtc.HasValue && token.RefreshExpiresUtc.Value < timeProvider.GetUtcNow().UtcDateTime) return OAuthTokenIssueResult.Invalid("invalid_grant", "Refresh token is expired."); - token.IsDisabled = true; - token.Refresh = null; - await tokenRepository.SaveAsync(token, o => o.ImmediateConsistency()); + var scopeValidation = ValidateRefreshScopes(token, client); + if (!scopeValidation.IsValid) + { + await RevokeOAuthGrantFamilyAsync(token); + return OAuthTokenIssueResult.Invalid("invalid_grant", "Refresh token is invalid."); + } - return OAuthTokenIssueResult.Success(await CreateTokenAsync(token.UserId!, token.OAuthClientId!, token.OAuthResource!, token.Scopes)); + var user = await userRepository.GetByIdAsync(token.UserId, o => o.ImmediateConsistency()); + if (user is null || !user.IsActive) + { + await RevokeOAuthGrantFamilyAsync(token); + return OAuthTokenIssueResult.Invalid("invalid_grant", "Refresh token is invalid."); + } + + var activeOrganizationIds = user.GetActiveOAuthOrganizationIds(token); + if (activeOrganizationIds.Count == 0) + { + await RevokeOAuthGrantFamilyAsync(token); + return OAuthTokenIssueResult.Invalid("invalid_grant", "Refresh token is invalid."); + } + + await DisableTokenAsync(token, clearRefresh: false); + return OAuthTokenIssueResult.Success(await CreateTokenAsync(token.UserId, token.ClientId, token.Resource, scopeValidation.Scopes, activeOrganizationIds, token.GrantId)); } - public async Task RevokeAsync(string? tokenValue) + public async Task RevokeAsync(string? tokenValue, string? clientId) { - if (String.IsNullOrWhiteSpace(tokenValue)) + if (String.IsNullOrWhiteSpace(tokenValue) || String.IsNullOrWhiteSpace(clientId)) return false; - var token = await tokenRepository.GetByIdAsync(tokenValue, o => o.ImmediateConsistency()); + clientId = clientId.Trim(); + string tokenHash = CreateTokenHash(tokenValue); + var accessTokenResults = await oauthTokenRepository.GetByAccessTokenHashAsync(tokenHash, o => o.ImmediateConsistency()); + var token = accessTokenResults.Documents.FirstOrDefault(); + bool isRefreshToken = false; + if (token is null) { - var results = await tokenRepository.GetByRefreshTokenAsync(tokenValue, o => o.ImmediateConsistency()); - token = results.Documents.FirstOrDefault(); + var refreshTokenResults = await oauthTokenRepository.GetByRefreshTokenHashAsync(tokenHash, o => o.ImmediateConsistency()); + token = refreshTokenResults.Documents.FirstOrDefault(); + isRefreshToken = token is not null; } - if (token is null || token.OAuthType != OAuthTokenType.Access) + if (token is null || !String.Equals(token.ClientId, clientId, StringComparison.Ordinal)) return false; - token.IsDisabled = true; - token.Refresh = null; - await tokenRepository.SaveAsync(token, o => o.ImmediateConsistency()); + if (isRefreshToken) + await RevokeOAuthGrantFamilyAsync(token); + else + await DisableTokenAsync(token); + return true; } - private async Task CreateTokenAsync(string userId, string clientId, string resource, IReadOnlyCollection scopes) + private async Task RevokeOAuthGrantFamilyAsync(OAuthToken token) + { + if (String.IsNullOrWhiteSpace(token.GrantId)) + { + await DisableTokenAsync(token); + return; + } + + var results = await oauthTokenRepository.GetByGrantIdAsync(token.GrantId, o => o.ImmediateConsistency().PageLimit(OAuthGrantFamilyPageLimit)); + IEnumerable familyTokens = results.Documents.Count > 0 ? results.Documents : [token]; + foreach (var familyToken in familyTokens.Where(t => String.Equals(t.GrantId, token.GrantId, StringComparison.Ordinal))) + await DisableTokenAsync(familyToken); + } + + private static RefreshScopeValidationResult ValidateRefreshScopes(OAuthToken token, OAuthClientOptions client) + { + if (!TryGetProtectedResourceByResourceUri(token.Resource, out var resourceDefinition)) + return RefreshScopeValidationResult.Invalid(); + + var allowedScopes = client.Scopes.ToHashSet(StringComparer.Ordinal); + var refreshedScopes = token.Scopes.Where(allowedScopes.Contains).ToArray(); + if (token.Scopes.Contains(AuthorizationRoles.OfflineAccess, StringComparer.Ordinal) && !refreshedScopes.Contains(AuthorizationRoles.OfflineAccess, StringComparer.Ordinal)) + return RefreshScopeValidationResult.Invalid(); + + if (resourceDefinition.RequiredScopes.Any(s => !refreshedScopes.Contains(s, StringComparer.Ordinal))) + return RefreshScopeValidationResult.Invalid(); + + var resourceScopes = refreshedScopes.Where(s => !String.Equals(s, AuthorizationRoles.OfflineAccess, StringComparison.Ordinal)).ToArray(); + if (resourceScopes.Length == 0 || resourceScopes.Any(s => !resourceDefinition.Scopes.Contains(s, StringComparer.Ordinal))) + return RefreshScopeValidationResult.Invalid(); + + return RefreshScopeValidationResult.Valid(refreshedScopes); + } + + private Task DisableTokenAsync(OAuthToken token, bool clearRefresh = true) + { + token.IsDisabled = true; + if (clearRefresh) + token.RefreshTokenHash = null; + + token.UpdatedUtc = timeProvider.GetUtcNow().UtcDateTime; + return oauthTokenRepository.SaveAsync(token, o => o.ImmediateConsistency()); + } + + private async Task CreateTokenAsync(string userId, string clientId, string resource, IReadOnlyCollection scopes, IReadOnlyCollection organizationIds, string? grantId = null) { var utcNow = timeProvider.GetUtcNow().UtcDateTime; - string accessToken = StringExtensions.GetNewToken(); - string? refreshToken = scopes.Contains(AuthorizationRoles.OfflineAccess, StringComparer.Ordinal) ? StringExtensions.GetNewToken() : null; - var token = new Token + var accessToken = CreateOAuthToken(); + var refreshToken = scopes.Contains(AuthorizationRoles.OfflineAccess, StringComparer.Ordinal) ? CreateOAuthToken() : null; + var token = new OAuthToken { - Id = accessToken, + Id = ObjectId.GenerateNewId().ToString(), UserId = userId, - Type = TokenType.Access, - OAuthType = OAuthTokenType.Access, - OAuthClientId = clientId, - OAuthResource = resource, - Refresh = refreshToken, + ClientId = clientId, + GrantId = String.IsNullOrWhiteSpace(grantId) ? StringExtensions.GetNewToken() : grantId, + Resource = resource, + AccessTokenHash = CreateTokenHash(accessToken), + RefreshTokenHash = refreshToken is null ? null : CreateTokenHash(refreshToken), Scopes = scopes.ToHashSet(StringComparer.Ordinal), + OrganizationIds = organizationIds.ToHashSet(StringComparer.Ordinal), CreatedUtc = utcNow, UpdatedUtc = utcNow, ExpiresUtc = utcNow.Add(options.AccessTokenLifetime), - OAuthRefreshExpiresUtc = refreshToken is null ? null : utcNow.Add(options.RefreshTokenLifetime), - CreatedBy = userId, - Notes = $"OAuth client: {clientId}" + RefreshExpiresUtc = refreshToken is null ? null : utcNow.Add(options.RefreshTokenLifetime), + CreatedBy = userId }; - await tokenRepository.AddAsync(token, o => o.ImmediateConsistency()); + await oauthTokenRepository.AddAsync(token, o => o.ImmediateConsistency()); return new OAuthTokenResponse { AccessToken = accessToken, @@ -439,7 +586,6 @@ private async Task CreateTokenAsync(string userId, string cl Resource = resource }; } - private static bool ValidateCodeVerifier(string challenge, string verifier) { return String.Equals(challenge, CreateCodeChallenge(verifier), StringComparison.Ordinal); @@ -458,7 +604,45 @@ 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; + } + + private static bool TryGetProtectedResourceByResourceUri(string? resource, out OAuthResourceDefinition resourceDefinition) + { + if (!String.IsNullOrWhiteSpace(resource) && Uri.TryCreate(resource, UriKind.Absolute, out var resourceUri) && String.IsNullOrEmpty(resourceUri.Query) && String.IsNullOrEmpty(resourceUri.Fragment)) + { + foreach (var candidate in ProtectedResources) + { + if (String.Equals(resourceUri.AbsolutePath.TrimEnd('/'), candidate.Path.TrimEnd('/'), StringComparison.Ordinal)) + { + 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; @@ -501,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; @@ -516,19 +700,44 @@ 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)); return Base64UrlEncode(bytes); } + public static bool IsOAuthTokenFormat(string? token) + { + return token?.Length == OAuthTokenLength; + } + + public static string CreateTokenHash(string token) + { + if (String.IsNullOrWhiteSpace(token)) + throw new ArgumentException("Token cannot be empty.", nameof(token)); + + return Base64UrlEncode(SHA256.HashData(Encoding.UTF8.GetBytes(token))); + } + + private static string CreateOAuthToken() => StringExtensions.GetRandomString(OAuthTokenLength); + private static string Base64UrlEncode(byte[] bytes) { return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); } private static string GetAuthorizationCodeCacheKey(string code) => AuthorizationCodeCachePrefix + code; - private static string GetRefreshTokenLockKey(string refreshToken) => RefreshTokenLockPrefix + Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken))).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + private static string GetRefreshTokenLockKey(string refreshToken) => RefreshTokenLockPrefix + CreateTokenHash(refreshToken); + private static string GetAccessTokenClientValidityCacheKey(string clientId) => AccessTokenClientValidityCachePrefix + Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(clientId))).TrimEnd('=').Replace('+', '-').Replace('/', '_'); } public static class OAuthGrantTypes @@ -547,6 +756,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,6 +833,16 @@ 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); + +internal sealed record RefreshScopeValidationResult(bool IsValid, IReadOnlyCollection Scopes) +{ + public static RefreshScopeValidationResult Valid(IReadOnlyCollection scopes) => new(true, scopes); + public static RefreshScopeValidationResult Invalid() => new(false, []); } public record OAuthValidationResult(bool IsValid, OAuthClientOptions? Client, IReadOnlyCollection Scopes, string? Error, string? ErrorDescription) 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 0805c80994..f08d45921c 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 @@ -376,19 +376,6 @@ export function resolveConfiguredTableOptions( return baseOptions; } -export function withClientSortedRowModel(options: TableOptions): TableOptions { - const features = tableFeatures({ - ...options.features, - sortedRowModel: createSortedRowModel(), - sortFns - }); - - return { - ...options, - features - }; -} - export function resolvePageCount( strategy: PaginationStrategy, meta: QueryMeta | undefined, @@ -438,6 +425,19 @@ export function resolvePaginationChange(previousPageInfo: PaginationState, curre }; } +export function withClientSortedRowModel(options: TableOptions): TableOptions { + const features = tableFeatures({ + ...options.features, + sortedRowModel: createSortedRowModel(), + sortFns + }); + + return { + ...options, + features + }; +} + function createPersistedTableState(key: string, initialValue: T): [() => T, (updater: Updater) => void] { const persistedValue = new PersistedState(key, initialValue); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts index a278afb31a..35d8f998ab 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts @@ -7,7 +7,7 @@ import { fetchApiJson } from '$features/shared/api/api.svelte'; import { type FetchClientResponse, ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query'; -import type { UpdateEmailAddressResult, UpdateUser, UpdateUserEmailAddress, ViewCurrentUser, ViewUser } from './models'; +import type { OAuthGrant, UpdateEmailAddressResult, UpdateUser, UpdateUserEmailAddress, ViewCurrentUser, ViewUser } from './models'; export async function invalidateUserQueries(queryClient: QueryClient, message: WebSocketMessageValue<'UserChanged'>) { const { id } = message; @@ -26,10 +26,12 @@ export async function invalidateUserQueries(queryClient: QueryClient, message: W export const queryKeys = { avatar: (id: string | undefined) => [...queryKeys.id(id), 'avatar'] as const, deleteCurrentUser: () => [...queryKeys.me(), 'delete'] as const, + deleteOAuthGrant: (id: string | undefined) => [...queryKeys.oauthGrants(), id, 'delete'] as const, id: (id: string | undefined) => [...queryKeys.type, id] as const, idEmailAddress: (id?: string) => [...queryKeys.id(id), 'email-address'] as const, ids: (ids: string[] | undefined) => [...queryKeys.type, ...(ids ?? [])] as const, me: () => [...queryKeys.type, 'me'] as const, + oauthGrants: () => [...queryKeys.me(), 'oauth-grants'] as const, organization: (id: string | undefined) => [...queryKeys.type, 'organization', id] as const, patchUser: (id: string | undefined) => [...queryKeys.id(id), 'patch'] as const, postEmailAddress: (id: string | undefined) => [...queryKeys.idEmailAddress(id), 'update'] as const, @@ -84,6 +86,21 @@ export function deleteCurrentUser() { })); } +export function deleteOAuthGrantMutation() { + const queryClient = useQueryClient(); + return createMutation(() => ({ + enabled: () => !!accessToken.current, + mutationFn: async (id: string) => { + const client = useFetchClient(); + await client.delete(`users/me/oauth-grants/${id}`); + }, + mutationKey: queryKeys.deleteOAuthGrant(undefined), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.oauthGrants() }); + } + })); +} + export function deleteUserAvatar(request: UserAvatarRequest) { const queryClient = useQueryClient(); return createMutation(() => ({ @@ -127,6 +144,21 @@ export function getMeQuery() { })); } +export function getOAuthGrantsQuery() { + return createQuery(() => ({ + enabled: () => !!accessToken.current, + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON('users/me/oauth-grants', { + signal + }); + + return response.data ?? []; + }, + queryKey: queryKeys.oauthGrants() + })); +} + export function getOrganizationUsersQuery(request: GetOrganizationUsersRequest) { const queryClient = useQueryClient(); 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 new file mode 100644 index 0000000000..27f4820f26 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-access-cell.svelte @@ -0,0 +1,82 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + +
+ {#each grant.resources as resource (resource.resource)} +
+
{formatResource(resource.resource)}
+
+ {#each resource.scopes as scope (scope)} + {formatScope(scope)} + {/each} +
+
+ {/each} +
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-actions-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-actions-cell.svelte new file mode 100644 index 0000000000..adaf05bea9 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-actions-cell.svelte @@ -0,0 +1,67 @@ + + + + + {#snippet child({ props })} + + {/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 0000000000..b7308acfe2 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-application-cell.svelte @@ -0,0 +1,20 @@ + + +
+
{grant.application_name}
+
Updated
+ {#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 0000000000..9b4cb90fa8 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grant-organizations-cell.svelte @@ -0,0 +1,47 @@ + + +{#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/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 0000000000..ba6a9c5d3f --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/oauth-grants-data-table.svelte @@ -0,0 +1,42 @@ + + + + {#if toolbarChildren} + + {@render toolbarChildren()} + + {/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 0000000000..454c7c78ab --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/components/oauth-grants/table/options.svelte.ts @@ -0,0 +1,77 @@ +import type { OAuthGrant } from '$features/users/models'; +import type { ProblemDetails } from '@exceptionless/fetchclient'; +import type { CreateQueryResult } from '@tanstack/svelte-query'; + +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-[40%] max-w-none whitespace-normal' + } + }, + { + accessorKey: 'resources', + cell: (info) => renderComponent(OAuthGrantAccessCell, { grant: info.row.original }), + enableHiding: false, + enableSorting: false, + header: 'Access', + meta: { + 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' + } + }, + { + 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-compact', + get columns() { + return getColumns(getOrganizationNamesById()); + }, + paginationStrategy: 'memory', + get queryData() { + return queryResponse.data ?? []; + }, + get queryParameters() { + return queryParameters; + } + }); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts index 67cafa617b..a262d71122 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts @@ -1,4 +1,4 @@ -export type { UpdateEmailAddressResult, ViewCurrentUser, ViewUser } from '$generated/api'; +export type { ViewOAuthGrant as OAuthGrant, UpdateEmailAddressResult, ViewCurrentUser, ViewUser } from '$generated/api'; export interface InviteUserForm { email: string; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index d14a158a62..1e8554a074 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -140,7 +140,7 @@ export interface NewSavedView { filter?: null | string; time?: null | string; sort?: null | string; - /** @pattern ^[a-z0-9]+(?:-[a-z0-9]+)*$ */ + /** @pattern ^(?![a-f0-9]{24}$)[a-z0-9]+(?:-[a-z0-9]+)*$ */ slug?: null | string; view_type: string; filter_definitions?: null | string; @@ -196,6 +196,81 @@ export interface OAuthAccount { extra_data: Record; } +export interface OAuthAuthorizationServerMetadata { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + registration_endpoint: string; + revocation_endpoint: string; + grant_types_supported: string[]; + response_types_supported: string[]; + code_challenge_methods_supported: string[]; + token_endpoint_auth_methods_supported: string[]; + scopes_supported: string[]; + resource_documentation: string; + client_id_metadata_document_supported: boolean; +} + +export interface OAuthAuthorizeConsentResponse { + client_id: string; + client_name: string; + redirect_uri: string; + resource: string; + scopes: string[]; + required_scopes: string[]; +} + +export interface OAuthAuthorizeForm { + client_id: string; + response_type: string; + redirect_uri: string; + scope?: null | string; + state?: null | string; + code_challenge: string; + code_challenge_method: string; + resource?: null | string; + organization_ids?: string[] | null; +} + +export interface OAuthClientRegistrationRequest { + redirect_uris?: string[] | null; + client_name?: null | string; + scope?: null | string; + grant_types?: string[] | null; + response_types?: string[] | null; + token_endpoint_auth_method?: null | string; +} + +export interface OAuthClientRegistrationResponse { + client_id: string; + client_name: string; + redirect_uris: string[]; + grant_types: string[]; + response_types: string[]; + scope: string; + token_endpoint_auth_method: string; + /** @format int64 */ + client_id_issued_at: number; +} + +export interface OAuthProtectedResourceMetadata { + resource: string; + authorization_servers: string[]; + scopes_supported: string[]; + bearer_methods_supported: string[]; + resource_documentation: string; +} + +export interface OAuthTokenResponse { + access_token: string; + token_type: string; + /** @format int32 */ + expires_in: number; + refresh_token?: null | string; + scope?: null | string; + resource?: null | string; +} + export interface PersistentEvent { /** * Unique id that identifies an event. @@ -507,6 +582,30 @@ export interface ViewCurrentUser { roles: string[]; } +export interface ViewOAuthGrant { + id: string; + client_id: string; + application_name: string; + is_application_disabled: boolean; + scopes: string[]; + organization_ids: string[]; + resources: ViewOAuthGrantResource[]; + /** @format date-time */ + created_utc: string; + /** @format date-time */ + updated_utc: string; + /** @format date-time */ + expires_utc?: null | string; + /** @format date-time */ + refresh_expires_utc?: null | string; +} + +export interface ViewOAuthGrantResource { + resource: string; + scopes: string[]; + organization_ids: string[]; +} + export interface ViewOrganization { /** @pattern ^[a-fA-F0-9]{24}$ */ id: string; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index e1471c492f..32ef74643c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -198,7 +198,10 @@ export const NewSavedViewSchema = object({ slug: string() .min(1, "Slug is required") .max(100, "Slug must be at most 100 characters") - .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Slug has invalid format") + .regex( + /^(?![a-f0-9]{24}$)[a-z0-9]+(?:-[a-z0-9]+)*$/, + "Slug has invalid format", + ) .nullable() .optional(), view_type: string().min(1, "View type is required"), @@ -278,6 +281,105 @@ export const OAuthAccountSchema = object({ }); export type OAuthAccountFormData = Infer; +export const OAuthAuthorizationServerMetadataSchema = object({ + issuer: string().min(1, "Issuer is required"), + authorization_endpoint: string().min(1, "Authorization endpoint is required"), + token_endpoint: string().min(1, "Token endpoint is required"), + registration_endpoint: string().min(1, "Registration endpoint is required"), + revocation_endpoint: string().min(1, "Revocation endpoint is required"), + grant_types_supported: array(string()), + response_types_supported: array(string()), + code_challenge_methods_supported: array(string()), + token_endpoint_auth_methods_supported: array(string()), + scopes_supported: array(string()), + resource_documentation: string().min(1, "Resource documentation is required"), + client_id_metadata_document_supported: boolean(), +}); +export type OAuthAuthorizationServerMetadataFormData = Infer< + typeof OAuthAuthorizationServerMetadataSchema +>; + +export const OAuthAuthorizeConsentResponseSchema = object({ + client_id: string().min(1, "Client id is required"), + client_name: string().min(1, "Client name is required"), + redirect_uri: string().min(1, "Redirect uri is required"), + resource: string().min(1, "Resource is required"), + scopes: array(string()), + required_scopes: array(string()), +}); +export type OAuthAuthorizeConsentResponseFormData = Infer< + typeof OAuthAuthorizeConsentResponseSchema +>; + +export const OAuthAuthorizeFormSchema = object({ + client_id: string().min(1, "Client id is required"), + response_type: string().min(1, "Response type is required"), + redirect_uri: string().min(1, "Redirect uri is required"), + scope: string().min(1, "Scope is required").nullable().optional(), + state: string().min(1, "State is required").nullable().optional(), + code_challenge: string().min(1, "Code challenge is required"), + code_challenge_method: string().min(1, "Code challenge method is required"), + resource: string().min(1, "Resource is required").nullable().optional(), + organization_ids: array(string()).nullable().optional(), +}); +export type OAuthAuthorizeFormFormData = Infer; + +export const OAuthClientRegistrationRequestSchema = object({ + redirect_uris: array(string()).nullable().optional(), + client_name: string().min(1, "Client name is required").nullable().optional(), + scope: string().min(1, "Scope is required").nullable().optional(), + grant_types: array(string()).nullable().optional(), + response_types: array(string()).nullable().optional(), + token_endpoint_auth_method: string() + .min(1, "Token endpoint auth method is required") + .nullable() + .optional(), +}); +export type OAuthClientRegistrationRequestFormData = Infer< + typeof OAuthClientRegistrationRequestSchema +>; + +export const OAuthClientRegistrationResponseSchema = object({ + client_id: string().min(1, "Client id is required"), + client_name: string().min(1, "Client name is required"), + redirect_uris: array(string()), + grant_types: array(string()), + response_types: array(string()), + scope: string().min(1, "Scope is required"), + token_endpoint_auth_method: string().min( + 1, + "Token endpoint auth method is required", + ), + client_id_issued_at: int(), +}); +export type OAuthClientRegistrationResponseFormData = Infer< + typeof OAuthClientRegistrationResponseSchema +>; + +export const OAuthProtectedResourceMetadataSchema = object({ + resource: string().min(1, "Resource is required"), + authorization_servers: array(string()), + scopes_supported: array(string()), + bearer_methods_supported: array(string()), + resource_documentation: string().min(1, "Resource documentation is required"), +}); +export type OAuthProtectedResourceMetadataFormData = Infer< + typeof OAuthProtectedResourceMetadataSchema +>; + +export const OAuthTokenResponseSchema = object({ + access_token: string().min(1, "Access token is required"), + token_type: string().min(1, "Token type is required"), + expires_in: int32(), + refresh_token: string() + .min(1, "Refresh token is required") + .nullable() + .optional(), + scope: string().min(1, "Scope is required").nullable().optional(), + resource: string().min(1, "Resource is required").nullable().optional(), +}); +export type OAuthTokenResponseFormData = Infer; + export const PersistentEventSchema = object({ id: string() .length(24, "Id must be exactly 24 characters") @@ -550,6 +652,30 @@ export const ViewCurrentUserSchema = object({ }); export type ViewCurrentUserFormData = Infer; +export const ViewOAuthGrantSchema = object({ + id: string().min(1, "Id is required"), + client_id: string().min(1, "Client id is required"), + application_name: string().min(1, "Application name is required"), + is_application_disabled: boolean(), + scopes: array(string()), + organization_ids: array(string()), + resources: array(lazy(() => ViewOAuthGrantResourceSchema)), + created_utc: iso.datetime(), + updated_utc: iso.datetime(), + expires_utc: iso.datetime().nullable().optional(), + refresh_expires_utc: iso.datetime().nullable().optional(), +}); +export type ViewOAuthGrantFormData = Infer; + +export const ViewOAuthGrantResourceSchema = object({ + resource: string().min(1, "Resource is required"), + scopes: array(string()), + organization_ids: array(string()), +}); +export type ViewOAuthGrantResourceFormData = Infer< + typeof ViewOAuthGrantResourceSchema +>; + export const ViewOrganizationSchema = object({ id: string() .length(24, "Id must be exactly 24 characters") 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 b22940e247..981c5a4a27 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; @@ -22,24 +22,19 @@ }; 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); } }); - const openCodeConfiguration = $derived(`{ - "$schema": "https://opencode.ai/config.json", - "mcp": { - "exceptionless": { - "type": "remote", - "url": "${mcpEndpoint}", - "oauth": {} + function getMcpServerName(hostname: string): string { + return hostname.toLowerCase() === 'dev-app.exceptionless.io' ? 'exceptionless-dev' : 'exceptionless'; } - } -}`); const aiTools = $derived([ { @@ -48,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' @@ -62,19 +57,32 @@ ] }, { - 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}`, - description: 'Register the streamable HTTP MCP server with Codex.', + 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' + } + ] + }, + { + description: 'Use Copilot with the hosted Exceptionless MCP server.', + id: 'github-copilot', + name: 'Copilot', + steps: [ + { + code: `copilot mcp add --transport http ${mcpServerName} ${mcpEndpoint}`, + description: 'Register the hosted HTTP MCP server with Copilot.', language: 'shellscript', title: 'Add the server' }, { - code: 'codex mcp login exceptionless', - description: 'Start the OAuth browser flow and approve access.', + code: 'copilot -i "List my Exceptionless projects"', + description: 'Start Copilot and approve the OAuth browser flow when prompted.', language: 'shellscript', title: 'Authenticate' } @@ -86,13 +94,13 @@ 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 ${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' 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 new file mode 100644 index 0000000000..4d0e44c4a3 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/applications/+page.svelte @@ -0,0 +1,66 @@ + + +
+ Manage applications connected to your account + + {#if grantsQuery.isError} +

Failed to load applications.

+ {:else} + + {/if} +
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 e252488de9..a2438359ca 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/ClientApp/src/routes/(auth)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/+layout.svelte index a378ce01d8..dd1a812756 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 4a16a64d90..dbc4fc10ff 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; @@ -21,8 +23,41 @@ redirect_uri?: string; } + interface OAuthAuthorizeConsentResponse { + client_id?: string; + client_name?: string; + error?: string; + error_description?: string; + redirect_uri?: string; + required_scopes?: string[]; + resource?: string; + scopes?: string[]; + } + + interface OAuthAuthorizeRequestBody { + client_id: null | string; + code_challenge: null | string; + code_challenge_method: null | string; + organization_ids: string[]; + redirect_uri: null | string; + resource: null | string; + response_type: null | string; + scope: null | string; + state: null | string; + } + + const offlineAccessScope = 'offline_access'; + const mcpReadScope = 'mcp:read'; + let errorMessage = $state(null); + let consentDetails = $state(null); + let consentErrorMessage = $state(null); let isAuthorizing = $state(false); + let isLoadingConsent = $state(false); + let loadedConsentKey = $state(null); + let initializedOrganizationSelectionKey = $state(null); + const selectedOrganizationIds = new SvelteSet(); + const selectedScopes = new SvelteSet(); const meQuery = getMeQuery(); const organizationsQuery = getOrganizationsQuery({ params: { mode: null } }); @@ -30,12 +65,22 @@ const clientId = $derived(page.url.searchParams.get('client_id') ?? 'Unknown application'); 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 || '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'); 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 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)); + const hasRequiredScopes = $derived(missingRequiredScopes.length === 0 && requiredScopes.every((scope) => selectedScopes.has(scope))); + const canApprove = $derived(!isLoadingConsent && !consentErrorMessage && hasSelectedOrganizations && hasSelectedResourceScope && hasRequiredScopes); $effect(() => { if (!browser || accessToken.current) { @@ -47,26 +92,115 @@ void goto(loginUrl, { replaceState: true }); }); + $effect(() => { + if (!browser || !accessToken.current) { + return; + } + + const consentKey = page.url.search; + if (loadedConsentKey === consentKey) { + return; + } + + loadedConsentKey = consentKey; + void loadConsentDetails(); + }); + + $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 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; + } + + selectedOrganizationIds.clear(); + for (const organizationId of validSelectedOrganizationIds) { + selectedOrganizationIds.add(organizationId); + } + }); + + $effect(() => { + selectedScopes.clear(); + for (const scope of requestedScopes) { + selectedScopes.add(scope); + } + }); + + async function loadConsentDetails(): Promise { + isLoadingConsent = true; + consentErrorMessage = null; + const client = useFetchClient(); + const response = await client.postJSON( + 'oauth/authorize/consent', + getAuthorizationRequestBody([], page.url.searchParams.get('scope')), + { expectedStatusCodes: [400, 401] } + ); + + isLoadingConsent = false; + if (response.ok && response.data) { + consentDetails = response.data; + return; + } + + if (response.status === 401) { + await redirectToLogin(); + return; + } + + consentDetails = null; + consentErrorMessage = + response.data?.error_description || + response.data?.error || + response.problem?.detail || + response.problem?.title || + 'Unable to load application details.'; + } + async function approveAuthorization(): Promise { if (isAuthorizing) { return; } + if (!hasSelectedOrganizations) { + errorMessage = 'Select at least one organization.'; + 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(); const response = await client.postJSON( 'oauth/authorize', - { - 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'), - 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'), - state: page.url.searchParams.get('state') - }, + getAuthorizationRequestBody([...selectedOrganizationIds], selectedScopeValues.join(' ')), { expectedStatusCodes: [400, 401] } ); @@ -77,10 +211,7 @@ isAuthorizing = false; if (response.status === 401) { - accessToken.current = null; - const returnUrl = `${page.url.pathname}${page.url.search}`; - const loginUrl = `${resolve('/(auth)/login')}?redirect=${encodeURIComponent(returnUrl)}`; - await goto(loginUrl, { replaceState: true }); + await redirectToLogin(); return; } @@ -92,6 +223,27 @@ 'Unable to authorize application.'; } + async function redirectToLogin(): Promise { + accessToken.current = null; + const returnUrl = `${page.url.pathname}${page.url.search}`; + const loginUrl = `${resolve('/(auth)/login')}?redirect=${encodeURIComponent(returnUrl)}`; + await goto(loginUrl, { replaceState: true }); + } + + function getAuthorizationRequestBody(organizationIds: string[], scope: null | string): OAuthAuthorizeRequestBody { + return { + 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: organizationIds, + redirect_uri: page.url.searchParams.get('redirect_uri'), + resource: page.url.searchParams.get('resource'), + response_type: page.url.searchParams.get('response_type'), + scope, + state: page.url.searchParams.get('state') + }; + } + function getRequestedScopes(): string[] { const scopes = page.url.searchParams @@ -100,7 +252,67 @@ .map((scope) => scope.trim()) .filter(Boolean) ?? []; - return scopes.length > 0 ? scopes : defaultRequestedScopes; + return scopes; + } + + function getRequiredScopes(resourceValue: string): string[] { + if (resourceValue.endsWith('/mcp')) { + return [mcpReadScope, offlineAccessScope]; + } + + 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; + } + + if (checked === true) { + selectedOrganizationIds.add(organizationId); + } else { + selectedOrganizationIds.delete(organizationId); + } + } + + 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() { @@ -108,15 +320,15 @@ } -
- +
+ - + Approve OAuth access Review the requested Exceptionless access before continuing. - -
+ +
Signed in as {#if meQuery.isLoading} @@ -124,25 +336,27 @@ {:else if meQuery.isError}

Unable to load account details.

{:else} -

{accountDisplayName}

-

{meQuery.data?.email_address}

+

{accountDisplayName}

+

{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.

@@ -150,36 +364,80 @@
-
-
+
+
Application -

{clientId}

+ {#if isLoadingConsent} +

Loading application...

+ {:else} +

{applicationDisplayName}

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

{applicationClientId}

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

{redirectUri}

+

{displayRedirectUri}

-
+
Resource -

{resource}

+

{displayResource}

-
- Scopes -
- {#each requestedScopes as scope (scope)} - {scope} +
+ +
+ Scopes + {#if requestedScopes.length > 0} +
+ {#each requestedRequiredScopes as scope (scope)} +
+ + + {formatScope(scope)} + Required + + {scope} + +
+ {/each} + {#each requestedOptionalScopes 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.

+ {/if}
- {#if errorMessage} - + {#if errorMessage || consentErrorMessage} + {/if} -