Skip to content
Draft
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
14 changes: 4 additions & 10 deletions src/Exceptionless.Core/Services/OAuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ public class OAuthService(OAuthServerOptions options, ICacheClient cacheClient,
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);
Expand Down Expand Up @@ -175,15 +174,10 @@ public async Task<bool> IsAccessTokenClientValidAsync(string? clientId)
return false;

clientId = clientId.Trim();
string cacheKey = GetAccessTokenClientValidityCacheKey(clientId);
bool? cached = await cacheClient.GetAsync<bool?>(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;
// An older lookup can complete after the application was disabled and its
// cache entry removed. Current application state authorizes the request.
var application = await oauthApplicationRepository.GetByClientIdAsync(clientId, o => o.Cache(false));
return application is { IsDisabled: false };
}

public Task ClearAccessTokenClientValidityCacheAsync(string? clientId)
Expand Down
12 changes: 8 additions & 4 deletions src/Exceptionless.Web/Security/ApiKeyAuthenticationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -268,9 +268,13 @@ private async Task<AuthenticateResult> AuthenticateOAuthBearerAsync(string token
return AuthenticateResult.Success(CreateOAuthUserAuthenticationTicket(user, tokenRecord, activeOAuthOrganizationIds));
}

private Task<Token?> GetTokenRecordAsync(string token)
private async Task<Token?> GetTokenRecordAsync(string token)
{
return _tokenRepository.GetByIdAsync(token, o => o.Cache());
var record = await _tokenRepository.GetByIdAsync(token, o => o.Cache());
// Delayed reads may repopulate a snapshot after logout or revocation.
if (record is not null && (record.Type == TokenType.Authentication || !String.IsNullOrEmpty(record.UserId)))
return await _tokenRepository.GetByIdAsync(token, o => o.Cache(false));
return record;
}

private async Task<OAuthToken?> GetOAuthTokenRecordAsync(string token)
Expand All @@ -283,14 +287,14 @@ private async Task<AuthenticateResult> AuthenticateOAuthBearerAsync(string token

if (!String.IsNullOrEmpty(tokenId))
{
var cachedToken = await _oauthTokenRepository.GetByIdAsync(tokenId, o => o.Cache());
var cachedToken = await _oauthTokenRepository.GetByIdAsync(tokenId, o => o.Cache(false));
if (cachedToken is not null && String.Equals(cachedToken.AccessTokenHash, accessTokenHash, StringComparison.Ordinal))
return cachedToken;

await _cacheClient.RemoveAsync(cacheKey);
}

var results = await _oauthTokenRepository.GetByAccessTokenHashAsync(accessTokenHash);
var results = await _oauthTokenRepository.GetByAccessTokenHashAsync(accessTokenHash, o => o.Cache(false));
var tokenRecord = results.Documents.FirstOrDefault();
await _cacheClient.SetAsync(cacheKey, tokenRecord?.Id ?? OAuthAccessTokenCacheMiss, TimeSpan.FromMinutes(5));

Expand Down
187 changes: 187 additions & 0 deletions tests/Exceptionless.Tests/Api/Endpoints/UserTokenStateEndpointTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
using System.Reflection;
using Exceptionless.Core;
using Exceptionless.Core.Authorization;
using Exceptionless.Core.Extensions;
using Exceptionless.Core.Models;
using Exceptionless.Core.Repositories;
using Exceptionless.Core.Repositories.Configuration;
using Exceptionless.Core.Services;
using Exceptionless.Core.Utility;
using Exceptionless.Core.Validation;
using Exceptionless.Tests.Extensions;
using Exceptionless.Web.Models;
using Exceptionless.Web.Models.Admin;
using FluentRest;
using Foundatio.Repositories;
using Foundatio.Repositories.Utility;
using Xunit;

namespace Exceptionless.Tests.Api.Endpoints;

public sealed class UserTokenStateEndpointTests(ITestOutputHelper output, AppWebHostFactory factory) : IntegrationTestsBase(output, factory)
{
protected override void RegisterServices(IServiceCollection services)
{
base.RegisterServices(services);
services.ReplaceSingleton<ITokenRepository, CachingTokenRepository>();
services.ReplaceSingleton<IOAuthTokenRepository, CachingOAuthTokenRepository>();
services.ReplaceSingleton<IOAuthApplicationRepository>(provider =>
{
var repository = new OAuthApplicationRepository(provider.GetRequiredService<ExceptionlessElasticConfiguration>(),
provider.GetRequiredService<MiniValidationValidator>(), provider.GetRequiredService<AppOptions>());
var proxy = DispatchProxy.Create<IOAuthApplicationRepository, PausingApplicationRepository>();
((PausingApplicationRepository)(object)proxy).Repository = repository;
return proxy;
});
}

protected override async Task ResetDataAsync()
{
await base.ResetDataAsync();
await GetService<SampleDataService>().CreateDataAsync();
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task AuthenticateAsync_ChangedUserTokenWithLateCacheWrite_ReturnsUnauthorized(bool logout)
{
var repository = (CachingTokenRepository)GetService<ITokenRepository>();
var login = await SendRequestAsAsync<TokenResult>(r => r.Post().AppendPath("auth/login")
.Content(new Login { Email = SampleDataService.TEST_USER_EMAIL, Password = SampleDataService.TEST_USER_PASSWORD })
.StatusCodeShouldBeOk());
Assert.NotNull(login);
if (!logout)
{
var access = await repository.GetByIdAsync(login.Token, o => o.Cache(false));
Assert.NotNull(access);
access.Type = TokenType.Access;
await repository.SaveAsync(access, o => o.ImmediateConsistency());
}
await SendRequestAsync(r => r.BearerToken(login.Token).AppendPath("users/me").StatusCodeShouldBeOk());
var stale = await repository.GetByIdAsync(login.Token, o => o.Cache(false));
Assert.NotNull(stale);
if (logout)
{
await SendRequestAsync(r => r.BearerToken(login.Token).AppendPath("auth/logout").StatusCodeShouldBeOk());
Assert.Null(await repository.GetByIdAsync(login.Token, o => o.Cache(false)));
}
else
{
var current = await repository.GetByIdAsync(login.Token, o => o.Cache(false));
Assert.NotNull(current);
current.IsDisabled = true;
await repository.SaveAsync(current, o => o.ImmediateConsistency());
}
await repository.CacheTokenAsync(stale);
await SendRequestAsync(r => r.BearerToken(login.Token).AppendPath("users/me").StatusCodeShouldBeUnauthorized());
await SendRequestAsync(r => r.BasicAuthorization("client", login.Token).AppendPath("users/me").StatusCodeShouldBeUnauthorized());
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task AuthenticateAsync_ChangedOAuthStateWithLateCacheWrite_ReturnsUnauthorized(bool disableApplication)
{
var applications = GetService<IOAuthApplicationRepository>();
var tokens = (CachingOAuthTokenRepository)GetService<IOAuthTokenRepository>();
var user = await GetService<IUserRepository>().GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL);
Assert.NotNull(user);
var now = TimeProvider.GetUtcNow().UtcDateTime;
var application = new OAuthApplication
{
Id = ObjectId.GenerateNewId().ToString(), ClientId = "late-cache-client", Name = "Late cache client",
RedirectUris = ["http://localhost/callback"], Scopes = [AuthorizationRoles.ProjectsRead],
CreatedByUserId = user.Id, UpdatedByUserId = user.Id, CreatedUtc = now, UpdatedUtc = now
};
await applications.AddAsync(application, o => o.ImmediateConsistency());
string accessToken = StringExtensions.GetRandomString(OAuthService.OAuthTokenLength);
var stale = new OAuthToken
{
Id = ObjectId.GenerateNewId().ToString(), UserId = user.Id, AuthenticationVersion = user.AuthenticationVersion,
ClientId = application.ClientId, GrantId = "late-cache-grant", Resource = "http://localhost:7110/api/v2",
AccessTokenHash = OAuthService.CreateTokenHash(accessToken), Scopes = [AuthorizationRoles.ProjectsRead],
OrganizationIds = [SampleDataService.TEST_ORG_ID], ExpiresUtc = now.AddHours(1),
CreatedBy = user.Id, CreatedUtc = now, UpdatedUtc = now
};
await tokens.AddAsync(stale, o => o.ImmediateConsistency());
await SendRequestAsync(r => r.BearerToken(accessToken).AppendPath("projects").StatusCodeShouldBeOk());

if (disableApplication)
{
var service = GetService<OAuthService>();
await service.ClearAccessTokenClientValidityCacheAsync(application.ClientId);
var pause = ((PausingApplicationRepository)(object)applications).PauseNextRead();
var validation = service.IsAccessTokenClientValidAsync(application.ClientId);
await pause.Read.Task.WaitAsync(TimeSpan.FromSeconds(10), TestCancellationToken);
try
{
await SendRequestAsync(r => r.Put().AsGlobalAdminUser().AppendPaths("admin", "oauth-applications", application.Id)
.Content(new UpdateOAuthApplication
{
ClientId = application.ClientId, Name = application.Name, RedirectUris = application.RedirectUris,
Scopes = application.Scopes, IsDisabled = true
}).StatusCodeShouldBeOk());
}
finally
{
pause.Resume.TrySetResult();
}
await validation;
}
else
{
// Revoke through the endpoint after the access-token lookup was warmed.
await SendRequestAsync(r => r.Post().AppendPath("oauth/revoke")
.Content(new FormUrlEncodedContent(new Dictionary<string, string>
{
["token"] = accessToken, ["client_id"] = application.ClientId
})).StatusCodeShouldBeOk());
var stored = await tokens.GetByIdAsync(stale.Id, o => o.Cache(false));
Assert.NotNull(stored);
Assert.True(stored.IsDisabled);
await tokens.CacheTokenAsync(stale);
}
await SendRequestAsync(r => r.BearerToken(accessToken).AppendPath("projects").StatusCodeShouldBeUnauthorized());
}

private sealed class CachingTokenRepository(ExceptionlessElasticConfiguration configuration, MiniValidationValidator validator, AppOptions options)
: TokenRepository(configuration, validator, options)
{
public Task CacheTokenAsync(Token token) => AddDocumentsToCacheAsync(token, new CommandOptions<Token>().Cache(), false);
}

private sealed class CachingOAuthTokenRepository(ExceptionlessElasticConfiguration configuration, MiniValidationValidator validator, AppOptions options)
: OAuthTokenRepository(configuration, validator, options)
{
public Task CacheTokenAsync(OAuthToken token) => AddDocumentsToCacheAsync(token, new CommandOptions<OAuthToken>().Cache(), false);
}

public class PausingApplicationRepository : DispatchProxy
{
public IOAuthApplicationRepository Repository { get; set; } = null!;
private ReadPause? _pause;
public ReadPause PauseNextRead() => _pause = new ReadPause();
protected override object? Invoke(MethodInfo? method, object?[]? args)
{
var result = method!.Invoke(Repository, args);
if (method.Name == nameof(IOAuthApplicationRepository.GetByClientIdAsync) && Interlocked.Exchange(ref _pause, null) is { } pause)
return WaitForReadAsync((Task<OAuthApplication?>)result!, pause);
return result;
}

private static async Task<OAuthApplication?> WaitForReadAsync(Task<OAuthApplication?> read, ReadPause pause)
{
var application = await read;
pause.Read.TrySetResult();
await pause.Resume.Task;
return application;
}
}

public sealed class ReadPause
{
public TaskCompletionSource Read { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource Resume { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public async Task AuthenticateAsync_OAuthCachedUser_RejectsMismatchWithoutRevoki
oauthTokens.Setup(repository => repository.GetByIdAsync(tokenId, It.IsAny<CommandOptionsDescriptor<OAuthToken>>())).ReturnsAsync(token);
oauthTokens.Setup(repository => repository.SaveAsync(token, It.IsAny<CommandOptionsDescriptor<OAuthToken>>())).ReturnsAsync(token);
var applications = new Mock<IOAuthApplicationRepository>(MockBehavior.Strict);
applications.Setup(repository => repository.GetByClientIdAsync(clientId, null)).ReturnsAsync(new OAuthApplication { ClientId = clientId });
applications.Setup(repository => repository.GetByClientIdAsync(clientId, It.IsAny<CommandOptionsDescriptor<OAuthApplication>>())).ReturnsAsync(new OAuthApplication { ClientId = clientId });
var oauthService = new OAuthService(new OAuthServerOptions(), cache, GetService<ILockProvider>(), applications.Object,
GetService<IOAuthClientMetadataService>(), oauthTokens.Object, users.Object, TimeProvider);
var options = new Mock<IOptionsMonitor<ApiKeyAuthenticationOptions>>(MockBehavior.Strict);
Expand All @@ -88,7 +88,7 @@ public async Task AuthenticateAsync_OAuthCachedUser_RejectsMismatchWithoutRevoki
oauthTokens.Verify(repository => repository.SaveAsync(token, It.IsAny<CommandOptionsDescriptor<OAuthToken>>()), Times.Exactly(isActive ? 0 : 1));
oauthTokens.Verify(repository => repository.GetByIdAsync(tokenId, It.IsAny<CommandOptionsDescriptor<OAuthToken>>()), Times.Once);
users.Verify(repository => repository.GetByIdAsync(user.Id, It.IsAny<CommandOptionsDescriptor<User>>()), Times.Once);
applications.Verify(repository => repository.GetByClientIdAsync(clientId, null), Times.Once);
applications.Verify(repository => repository.GetByClientIdAsync(clientId, It.IsAny<CommandOptionsDescriptor<OAuthApplication>>()), Times.Once);
oauthTokens.VerifyNoOtherCalls();
users.VerifyNoOtherCalls();
applications.VerifyNoOtherCalls();
Expand Down Expand Up @@ -149,7 +149,7 @@ public async Task AuthenticateAsync_AuthenticationVersion_RequiresActiveUserAndM
// Assert
Assert.Equal(expectedSuccess, result.Succeeded);
users.Verify(repository => repository.GetByIdAsync(user.Id, It.IsAny<CommandOptionsDescriptor<User>>()), Times.Once);
tokens.Verify(repository => repository.GetByIdAsync(tokenId, It.IsAny<CommandOptionsDescriptor<Token>>()), Times.Once);
tokens.Verify(repository => repository.GetByIdAsync(tokenId, It.IsAny<CommandOptionsDescriptor<Token>>()), Times.Exactly(2));
users.VerifyNoOtherCalls();
tokens.VerifyNoOtherCalls();
oauthTokens.VerifyNoOtherCalls();
Expand Down
1 change: 1 addition & 0 deletions tests/http/oauth.http
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
# Copy code from the authorization response's redirect_uri into authorizationCode. Each code can be exchanged once.
@rootUrl = http://localhost:7110
@apiUrl = {{rootUrl}}/api/v2
# Bearer authentication checks current OAuth token and application state after revocation or disablement.
@email = admin@exceptionless.test
@password = tester
@organizationId = 537650f3b77efe23a47914f3
Expand Down
Loading