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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/Exceptionless.Core/Jobs/CleanupDataJob.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ namespace Exceptionless.Core.Jobs;
public class CleanupDataJob : JobWithLockBase, IHealthCheck
{
private static readonly TimeSpan OAuthTokenCleanupSafetyWindow = TimeSpan.FromDays(1);
private static readonly TimeSpan OAuthApplicationCleanupSafetyWindow = TimeSpan.FromDays(1);
private static readonly TimeSpan SyntheticOrganizationCleanupSafetyWindow = TimeSpan.FromDays(1);
private static readonly TimeSpan SyntheticUserCleanupSafetyWindow = TimeSpan.FromDays(1);
private const string SyntheticOrganizationNamePrefix = "E2E Playwright Org";
Expand All @@ -38,6 +39,7 @@ public class CleanupDataJob : JobWithLockBase, IHealthCheck
private readonly IEventRepository _eventRepository;
private readonly ITokenRepository _tokenRepository;
private readonly IOAuthTokenRepository _oauthTokenRepository;
private readonly IOAuthApplicationRepository _oauthApplicationRepository;
private readonly IWebHookRepository _webHookRepository;
private readonly BillingManager _billingManager;
private readonly UsageService _usageService;
Expand All @@ -58,6 +60,7 @@ public CleanupDataJob(
IEventRepository eventRepository,
ITokenRepository tokenRepository,
IOAuthTokenRepository oauthTokenRepository,
IOAuthApplicationRepository oauthApplicationRepository,
IWebHookRepository webHookRepository,
ILockProvider lockProvider,
ICacheClient cacheClient,
Expand All @@ -80,6 +83,7 @@ ILoggerFactory loggerFactory
_eventRepository = eventRepository;
_tokenRepository = tokenRepository;
_oauthTokenRepository = oauthTokenRepository;
_oauthApplicationRepository = oauthApplicationRepository;
_webHookRepository = webHookRepository;
_billingManager = billingManager;
_billingPlans = billingPlans;
Expand All @@ -103,6 +107,7 @@ protected override async Task<JobResult> RunInternalAsync(JobContext context)
bool canCleanupSourceMaps = await FlushSourceMapUsagesAsync(context.CancellationToken);

await MarkTokensSuspended(context);
await CleanupOAuthApplicationsAsync(context);
await CleanupOAuthTokensAsync(context);
await CleanupSyntheticOrganizationsAsync(context);
await CleanupSyntheticUsersAsync(context);
Expand Down Expand Up @@ -140,6 +145,65 @@ private async Task CleanupOAuthTokensAsync(JobContext context)
_logger.LogInformation("Removed {OAuthTokenCount} expired disabled OAuth token(s)", removed);
}

private async Task CleanupOAuthApplicationsAsync(JobContext context)
{
var utcCutoff = _timeProvider.GetUtcNow().UtcDateTime.Subtract(OAuthApplicationCleanupSafetyWindow);
var applications = await _oauthApplicationRepository.FindAsync(
query => AbandonedApplications(query).SortAscending(application => application.Id),
options => options.SearchAfterPaging().PageLimit(500));

long removed = 0;
while (applications.Documents.Count > 0 && !context.CancellationToken.IsCancellationRequested)
{
// Legacy applications may have tokens before their organization associations are backfilled.
string[] clientIds = applications.Documents.Select(application => application.ClientId).Distinct(StringComparer.Ordinal).ToArray();
var tokens = await _oauthTokenRepository.FindAsync(
query => query.FieldEquals(token => token.ClientId, clientIds).Include(token => token.ClientId, token => token.OrganizationIds).SortAscending(token => token.Id),
options => options.SearchAfterPaging().PageLimit(500));
var authorizedClientIds = new HashSet<string>(StringComparer.Ordinal);
do
{
context.CancellationToken.ThrowIfCancellationRequested();
authorizedClientIds.UnionWith(tokens.Documents.Select(token => token.ClientId));
foreach (var clientTokens in tokens.Documents.GroupBy(token => token.ClientId, StringComparer.Ordinal))
{
// Retain authorization history even after the legacy tokens themselves are cleaned up.
string[] organizationIds = clientTokens.SelectMany(token => token.OrganizationIds).Distinct(StringComparer.Ordinal).ToArray();
await _oauthApplicationRepository.AddOrganizationIdsAsync(clientTokens.Key, organizationIds, options => options.ImmediateConsistency().Notifications(false));
}
} while (!context.CancellationToken.IsCancellationRequested && await tokens.NextPageAsync());

context.CancellationToken.ThrowIfCancellationRequested();

string[] abandonedIds = applications.Documents
.Where(application => !authorizedClientIds.Contains(application.ClientId))
.Select(application => application.Id)
.ToArray();
if (abandonedIds.Length > 0)
{
// Recheck eligibility when deleting. Delete-by-query skips concurrent consent or administrator updates.
removed += await _oauthApplicationRepository.RemoveAllAsync(
query => AbandonedApplications(query).Id(abandonedIds),
options => options.Cache(false).ImmediateConsistency());
}

await RenewLockAsync(context);
if (!await applications.NextPageAsync())
break;
}

_logger.LogInformation("Removed {OAuthApplicationCount} abandoned OAuth application(s)", removed);

IRepositoryQuery<OAuthApplication> AbandonedApplications(IRepositoryQuery<OAuthApplication> query) => query
.FieldEquals(application => application.CreatedByUserId, OAuthApplication.SystemUserId)
.FieldOr(group => group
.FieldEquals(application => application.UpdatedByUserId, OAuthApplication.SystemUserId)
.FieldEmpty(application => application.UpdatedByUserId))
.FieldEquals(application => application.IsDisabled, false)
.FieldEmpty(application => application.OrganizationIds)
.DateRange(null, utcCutoff, (OAuthApplication application) => application.UpdatedUtc);
}

private async Task CleanupSyntheticOrganizationsAsync(JobContext context)
{
var utcCutoff = _timeProvider.GetUtcNow().UtcDateTime.Subtract(SyntheticOrganizationCleanupSafetyWindow);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ public interface IOAuthApplicationRepository : ISearchableRepository<OAuthApplic
Task<long> AddOrganizationIdsAsync(string clientId, IReadOnlyCollection<string> organizationIds, CommandOptionsDescriptor<OAuthApplication>? options = null);
Task<OAuthApplication?> GetByClientIdAsync(string clientId, CommandOptionsDescriptor<OAuthApplication>? options = null);
Task<FindResults<OAuthApplication>> GetByCriteriaAsync(string? criteria, IReadOnlyCollection<string>? organizationIds, CommandOptionsDescriptor<OAuthApplication>? options = null);
Task<FindResults<OAuthApplication>> GetByCriteriaAsync(string? criteria, IReadOnlyCollection<string>? organizationIds, bool? authorized, string? sort, CommandOptionsDescriptor<OAuthApplication>? options = null);
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ public Task<long> AddOrganizationIdsAsync(string clientId, IReadOnlyCollection<s
}

public Task<FindResults<OAuthApplication>> GetByCriteriaAsync(string? criteria, IReadOnlyCollection<string>? organizationIds, CommandOptionsDescriptor<OAuthApplication>? options = null)
=> GetByCriteriaAsync(criteria, organizationIds, null, null, options);

public Task<FindResults<OAuthApplication>> GetByCriteriaAsync(string? criteria, IReadOnlyCollection<string>? organizationIds, bool? authorized, string? sort, CommandOptionsDescriptor<OAuthApplication>? options = null)
{
var query = new RepositoryQuery<OAuthApplication>();

Expand All @@ -66,7 +69,16 @@ public Task<FindResults<OAuthApplication>> GetByCriteriaAsync(string? criteria,
if (organizationIds is { Count: > 0 })
query.FieldEquals(application => application.OrganizationIds, organizationIds);

query.SortAscending(application => application.Name);
if (authorized is true)
query.FieldHasValue(application => application.OrganizationIds);
else if (authorized is false)
query.FieldEmpty(application => application.OrganizationIds);

if (!String.IsNullOrWhiteSpace(sort))
query.SortExpression($"{sort} id");
else
query.SortAscending(application => application.Name).SortAscending(application => application.Id);

return FindAsync(q => query, options);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ public static IEndpointRouteBuilder MapOAuthApplicationEndpoints(this IEndpointR
.AddEndpointFilter<AutoValidationEndpointFilter>()
.ExcludeFromDescription();

endpoints.MapGet("api/v2/admin/oauth-applications", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper<HttpIResult> resultMapper, string? criteria = null, string? organization = null, int page = 1, int limit = 100)
=> (await mediator.InvokeAsync<Result<PagedResult<ViewOAuthApplication>>>(new GetOAuthApplications(criteria, organization, page, limit, httpContext))).ToHttpResult(resultMapper))
endpoints.MapGet("api/v2/admin/oauth-applications", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper<HttpIResult> resultMapper, string? criteria = null, string? organization = null, int page = 1, int limit = 100, bool? authorized = null, string? sort = null)
=> (await mediator.InvokeAsync<Result<PagedResult<ViewOAuthApplication>>>(new GetOAuthApplications(criteria, organization, page, limit, httpContext, authorized, sort))).ToHttpResult(resultMapper))
.RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy)
.AddEndpointFilter<AutoValidationEndpointFilter>()
.Produces<IReadOnlyCollection<ViewOAuthApplication>>()
Expand Down
6 changes: 4 additions & 2 deletions src/Exceptionless.Web/Api/Handlers/OAuthApplicationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public async Task<Result<PagedResult<ViewOAuthApplication>>> Handle(GetOAuthAppl
if (!String.IsNullOrWhiteSpace(message.Organization) && organizationIds.Count == 0)
return new PagedResult<ViewOAuthApplication>([], false, page, 0);

var results = await repository.GetByCriteriaAsync(message.Criteria, organizationIds, o => o.PageNumber(page).PageLimit(limit));
var results = await repository.GetByCriteriaAsync(message.Criteria, organizationIds, message.Authorized, message.Sort, o => o.PageNumber(page).PageLimit(limit));
var applications = await MapApplicationsAsync(results.Documents);
return new PagedResult<ViewOAuthApplication>(applications, results.HasMore && !Pagination.NextPageExceedsSkipLimit(page, limit), page, results.Total);
}
Expand Down Expand Up @@ -158,7 +158,9 @@ private async Task<IReadOnlyCollection<ViewOAuthApplication>> MapApplicationsAsy
var organizations = organizationIds.Length > 0
? await organizationRepository.GetByIdsAsync(organizationIds, options => options.Cache())
: [];
var organizationNames = organizations.ToDictionary(organization => organization.Id, organization => organization.Name, StringComparer.Ordinal);
var organizationNames = organizations
.Where(organization => !organization.IsDeleted)
.ToDictionary(organization => organization.Id, organization => organization.Name, StringComparer.Ordinal);
return applications.Select(application => ViewOAuthApplication.FromApplication(application, organizationNames)).ToArray();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace Exceptionless.Web.Api.Messages;

public record GetOAuthApplications(string? Criteria, string? Organization, int Page, int Limit, HttpContext Context);
public record GetOAuthApplications(string? Criteria, string? Organization, int Page, int Limit, HttpContext Context, bool? Authorized = null, string? Sort = null);
public record GetOAuthApplication(string Id);
public record CreateOAuthApplicationMessage(NewOAuthApplication Model, HttpContext Context);
public record UpdateOAuthApplicationMessage(string Id, UpdateOAuthApplication Model, HttpContext Context);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { OAuthApplication } from '../../src/lib/features/admin/models';

import { expect, test } from '../fixtures/e2e-test';

test('OAuth applications default to authorized and expose configuration and organization links', async ({ e2eScenario, page }) => {
await page.setViewportSize({ height: 1100, width: 1440 });
const authorizedApplication: OAuthApplication = {
client_id: 'https://client.example/oauth/client-metadata',
created_by_user_id: '000000000000000000000001',
created_utc: '2026-09-01T12:00:00Z',
id: '000000000000000000000101',
is_disabled: false,
name: 'Recently authorized application',
notes: 'OAuth configuration for browser verification.',
organizations: [
{ id: e2eScenario.organizationId, is_available: true, name: e2eScenario.organizationName },
{ id: '000000000000000000000103', is_available: true, name: 'Second authorized organization' },
{ id: '000000000000000000000104', is_available: false, name: '000000000000000000000104' }
],
redirect_uris: ['https://client.example/oauth/callback', 'http://localhost:54321/callback'],
scopes: ['mcp:read', 'events:read'],
updated_utc: '2026-09-12T12:00:00Z'
};
const unauthorizedApplication: OAuthApplication = {
...authorizedApplication,
client_id: 'dcr_pending-application',
id: '000000000000000000000102',
name: 'Pending application',
organizations: [],
updated_utc: '2026-09-11T12:00:00Z'
};

await page.route('**/api/v2/admin/oauth-applications?*', async (route) => {
const params = new URL(route.request().url()).searchParams;
const applications =
params.get('authorized') === 'true'
? [authorizedApplication]
: params.get('authorized') === 'false'
? [unauthorizedApplication]
: [authorizedApplication, unauthorizedApplication];
await route.fulfill({ json: applications });
});

const initialRequest = page.waitForRequest((request) => request.url().includes('/api/v2/admin/oauth-applications?'));
await page.goto('/next/system/oauth-applications');
const params = new URL((await initialRequest).url()).searchParams;
expect(params.get('authorized')).toBe('true');
expect(params.get('sort')).toBe('-updated_utc');
await expect(page.getByRole('button', { name: 'Filter by authorization' })).toHaveText('Authorized');
await expect(page.getByRole('link', { exact: true, name: authorizedApplication.name })).toBeVisible();
await expect(page.getByRole('link', { exact: true, name: unauthorizedApplication.name })).toHaveCount(0);
await expect(page.getByRole('columnheader', { name: 'Client ID' })).toHaveCount(0);
await expect(page.getByText(authorizedApplication.client_id, { exact: true })).toHaveCount(0);
await expect(page.getByText('000000000000000000000104', { exact: true })).toBeVisible();
await expect(page.getByRole('link', { exact: true, name: '000000000000000000000104' })).toHaveCount(0);

await page.getByRole('button', { name: `Show details for ${authorizedApplication.name}` }).click();
await expect(page.getByText(authorizedApplication.client_id, { exact: true })).toBeVisible();
for (const uri of authorizedApplication.redirect_uris) {
await expect(page.getByText(uri, { exact: true })).toBeVisible();
}
await expect(page.getByRole('button', { name: /copy/i })).toHaveCount(0);
await expect(page.getByRole('link', { exact: true, name: 'Edit application' })).toHaveAttribute(
'href',
`/next/system/oauth-applications/${authorizedApplication.id}`
);
await expect(page.getByRole('link', { exact: true, name: 'Second authorized organization' })).toHaveAttribute(
'href',
'/next/organization/000000000000000000000103/manage'
);

await page.screenshot({ fullPage: true, path: test.info().outputPath('oauth-application-details.png') });

await page.getByRole('button', { name: `Hide details for ${authorizedApplication.name}` }).click();
await expect(page.getByText(authorizedApplication.client_id, { exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Filter by authorization' }).click();
await page.getByRole('option', { exact: true, name: 'Not authorized' }).click();
await expect(page.getByRole('link', { exact: true, name: unauthorizedApplication.name })).toBeVisible();
await expect(page.getByRole('link', { exact: true, name: authorizedApplication.name })).toHaveCount(0);
await expect(page).toHaveURL(/authorization=unauthorized/);

await page.getByRole('button', { name: 'Filter by authorization' }).click();
await page.getByRole('option', { exact: true, name: 'All applications' }).click();
await expect(page.getByRole('link', { exact: true, name: authorizedApplication.name })).toBeVisible();
await expect(page.getByRole('link', { exact: true, name: unauthorizedApplication.name })).toBeVisible();
await expect(page).toHaveURL(/authorization=all/);
await page.reload();
await expect(page.getByRole('button', { name: 'Filter by authorization' })).toHaveText('All applications');

await page.getByRole('link', { exact: true, name: e2eScenario.organizationName }).click();
await expect(page).toHaveURL(new RegExp(`/next/organization/${e2eScenario.organizationId}/manage`));
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ import type {
} from './models';

export type GetOAuthApplicationsParams = {
authorized?: boolean;
criteria?: string;
limit?: number;
organization?: string;
page?: number;
sort?: string;
};

export type GetOAuthApplicationsRequest = {
Expand Down

This file was deleted.

Loading
Loading