Files
git.stella-ops.org/src/StellaOps.Authority/StellaOps.Authority.Tests/OpenIddict/TokenPersistenceIntegrationTests.cs
2025-10-18 20:46:16 +03:00

282 lines
11 KiB
C#

using System.Collections.Generic;
using System.Diagnostics;
using System.Security.Claims;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using MongoDB.Driver;
using MongoDB.Bson;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using StellaOps.Authority;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.OpenIddict.Handlers;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Extensions;
using StellaOps.Authority.Storage.Mongo.Initialization;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Concelier.Testing;
using StellaOps.Authority.RateLimiting;
using StellaOps.Cryptography.Audit;
using Xunit;
namespace StellaOps.Authority.Tests.OpenIddict;
[Collection("mongo-fixture")]
public sealed class TokenPersistenceIntegrationTests
{
private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests.Persistence");
private readonly MongoIntegrationFixture fixture;
public TokenPersistenceIntegrationTests(MongoIntegrationFixture fixture)
=> this.fixture = fixture ?? throw new ArgumentNullException(nameof(fixture));
[Fact]
public async Task HandleClientCredentials_PersistsTokenInMongo()
{
await ResetCollectionsAsync();
var issuedAt = new DateTimeOffset(2025, 10, 10, 12, 0, 0, TimeSpan.Zero);
var clock = new FakeTimeProvider(issuedAt);
await using var provider = await BuildMongoProviderAsync(clock);
var clientStore = provider.GetRequiredService<IAuthorityClientStore>();
var tokenStore = provider.GetRequiredService<IAuthorityTokenStore>();
var clientDocument = TestHelpers.CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:trigger jobs:read");
await clientStore.UpsertAsync(clientDocument, CancellationToken.None);
var registry = TestHelpers.CreateRegistry(
withClientProvisioning: true,
clientDescriptor: TestHelpers.CreateDescriptor(clientDocument));
var authSink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var validateHandler = new ValidateClientCredentialsHandler(clientStore, registry, TestActivitySource, authSink, metadataAccessor, clock, NullLogger<ValidateClientCredentialsHandler>.Instance);
var handleHandler = new HandleClientCredentialsHandler(registry, tokenStore, clock, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance);
var persistHandler = new PersistTokensHandler(tokenStore, clock, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);
var transaction = TestHelpers.CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:trigger");
transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(15);
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validateHandler.HandleAsync(validateContext);
Assert.False(validateContext.IsRejected);
var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
await handleHandler.HandleAsync(handleContext);
Assert.True(handleContext.IsRequestHandled);
var principal = Assert.IsType<ClaimsPrincipal>(handleContext.Principal);
var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId);
Assert.False(string.IsNullOrWhiteSpace(tokenId));
var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction)
{
Principal = principal,
AccessTokenPrincipal = principal
};
await persistHandler.HandleAsync(signInContext);
var stored = await tokenStore.FindByTokenIdAsync(tokenId!, CancellationToken.None);
Assert.NotNull(stored);
Assert.Equal(clientDocument.ClientId, stored!.ClientId);
Assert.Equal(OpenIddictConstants.TokenTypeHints.AccessToken, stored.Type);
Assert.Equal("valid", stored.Status);
Assert.Equal(issuedAt, stored.CreatedAt);
Assert.Equal(issuedAt.AddMinutes(15), stored.ExpiresAt);
Assert.Equal(new[] { "jobs:trigger" }, stored.Scope);
}
[Fact]
public async Task ValidateAccessTokenHandler_RejectsRevokedRefreshTokenPersistedInMongo()
{
await ResetCollectionsAsync();
var now = new DateTimeOffset(2025, 10, 10, 14, 0, 0, TimeSpan.Zero);
var clock = new FakeTimeProvider(now);
await using var provider = await BuildMongoProviderAsync(clock);
var clientStore = provider.GetRequiredService<IAuthorityClientStore>();
var tokenStore = provider.GetRequiredService<IAuthorityTokenStore>();
var clientDocument = TestHelpers.CreateClient(
secret: null,
clientType: "public",
allowedGrantTypes: "password refresh_token",
allowedScopes: "openid profile jobs:read");
await clientStore.UpsertAsync(clientDocument, CancellationToken.None);
var descriptor = TestHelpers.CreateDescriptor(clientDocument);
var userDescriptor = new AuthorityUserDescriptor("subject-1", "alice", displayName: "Alice", requiresPasswordReset: false);
var plugin = TestHelpers.CreatePlugin(
name: clientDocument.Plugin ?? "standard",
supportsClientProvisioning: true,
descriptor,
userDescriptor);
var registry = new AuthorityIdentityProviderRegistry(
new[] { plugin },
NullLogger<AuthorityIdentityProviderRegistry>.Instance);
const string revokedTokenId = "refresh-token-1";
var refreshToken = new AuthorityTokenDocument
{
TokenId = revokedTokenId,
Type = OpenIddictConstants.TokenTypeHints.RefreshToken,
SubjectId = userDescriptor.SubjectId,
ClientId = clientDocument.ClientId,
Scope = new List<string> { "openid", "profile" },
Status = "valid",
CreatedAt = now.AddMinutes(-5),
ExpiresAt = now.AddHours(4),
ReferenceId = "refresh-reference-1"
};
await tokenStore.InsertAsync(refreshToken, CancellationToken.None);
var revokedAt = now.AddMinutes(1);
await tokenStore.UpdateStatusAsync(revokedTokenId, "revoked", revokedAt, "manual", null, null, CancellationToken.None);
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var auditSink = new TestAuthEventSink();
var handler = new ValidateAccessTokenHandler(
tokenStore,
clientStore,
registry,
metadataAccessor,
auditSink,
clock,
TestActivitySource,
NullLogger<ValidateAccessTokenHandler>.Instance);
var transaction = new OpenIddictServerTransaction
{
EndpointType = OpenIddictServerEndpointType.Token,
Options = new OpenIddictServerOptions(),
Request = new OpenIddictRequest
{
GrantType = OpenIddictConstants.GrantTypes.RefreshToken
}
};
var principal = TestHelpers.CreatePrincipal(
clientDocument.ClientId,
revokedTokenId,
plugin.Name,
userDescriptor.SubjectId);
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
{
Principal = principal,
TokenId = revokedTokenId
};
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error);
var stored = await tokenStore.FindByTokenIdAsync(revokedTokenId, CancellationToken.None);
Assert.NotNull(stored);
Assert.Equal("revoked", stored!.Status);
Assert.Equal(revokedAt, stored.RevokedAt);
Assert.Equal("manual", stored.RevokedReason);
}
[Fact]
public async Task RecordUsageAsync_FlagsSuspectedReplay_OnNewDeviceFingerprint()
{
await ResetCollectionsAsync();
var issuedAt = new DateTimeOffset(2025, 10, 14, 8, 0, 0, TimeSpan.Zero);
var clock = new FakeTimeProvider(issuedAt);
await using var provider = await BuildMongoProviderAsync(clock);
var tokenStore = provider.GetRequiredService<IAuthorityTokenStore>();
var tokenDocument = new AuthorityTokenDocument
{
TokenId = "token-replay",
Type = OpenIddictConstants.TokenTypeHints.AccessToken,
ClientId = "client-1",
Status = "valid",
CreatedAt = issuedAt,
Devices = new List<BsonDocument>
{
new BsonDocument
{
{ "remoteAddress", "10.0.0.1" },
{ "userAgent", "agent/1.0" },
{ "firstSeen", BsonDateTime.Create(issuedAt.AddMinutes(-10).UtcDateTime) },
{ "lastSeen", BsonDateTime.Create(issuedAt.AddMinutes(-5).UtcDateTime) },
{ "useCount", 2 }
}
}
};
await tokenStore.InsertAsync(tokenDocument, CancellationToken.None);
var result = await tokenStore.RecordUsageAsync(
"token-replay",
remoteAddress: "10.0.0.2",
userAgent: "agent/2.0",
observedAt: clock.GetUtcNow(),
CancellationToken.None);
Assert.Equal(TokenUsageUpdateStatus.SuspectedReplay, result.Status);
var stored = await tokenStore.FindByTokenIdAsync("token-replay", CancellationToken.None);
Assert.NotNull(stored);
Assert.Equal(2, stored!.Devices?.Count);
Assert.Contains(stored.Devices!, doc =>
{
var remote = doc.TryGetValue("remoteAddress", out var ra) && ra.IsString ? ra.AsString : null;
var agentValue = doc.TryGetValue("userAgent", out var ua) && ua.IsString ? ua.AsString : null;
return remote == "10.0.0.2" && agentValue == "agent/2.0";
});
}
private async Task ResetCollectionsAsync()
{
var tokens = fixture.Database.GetCollection<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens);
await tokens.DeleteManyAsync(Builders<AuthorityTokenDocument>.Filter.Empty);
var clients = fixture.Database.GetCollection<AuthorityClientDocument>(AuthorityMongoDefaults.Collections.Clients);
await clients.DeleteManyAsync(Builders<AuthorityClientDocument>.Filter.Empty);
}
private async Task<ServiceProvider> BuildMongoProviderAsync(FakeTimeProvider clock)
{
var services = new ServiceCollection();
services.AddSingleton<TimeProvider>(clock);
services.AddLogging();
services.AddAuthorityMongoStorage(options =>
{
options.ConnectionString = fixture.Runner.ConnectionString;
options.DatabaseName = fixture.Database.DatabaseNamespace.DatabaseName;
options.CommandTimeout = TimeSpan.FromSeconds(5);
});
var provider = services.BuildServiceProvider();
var initializer = provider.GetRequiredService<AuthorityMongoInitializer>();
var database = provider.GetRequiredService<IMongoDatabase>();
await initializer.InitialiseAsync(database, CancellationToken.None);
return provider;
}
}