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(); var tokenStore = provider.GetRequiredService(); 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.Instance); var handleHandler = new HandleClientCredentialsHandler(registry, tokenStore, clock, TestActivitySource, NullLogger.Instance); var persistHandler = new PersistTokensHandler(tokenStore, clock, TestActivitySource, NullLogger.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(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(); var tokenStore = provider.GetRequiredService(); 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.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 { "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.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(); var tokenDocument = new AuthorityTokenDocument { TokenId = "token-replay", Type = OpenIddictConstants.TokenTypeHints.AccessToken, ClientId = "client-1", Status = "valid", CreatedAt = issuedAt, Devices = new List { 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(AuthorityMongoDefaults.Collections.Tokens); await tokens.DeleteManyAsync(Builders.Filter.Empty); var clients = fixture.Database.GetCollection(AuthorityMongoDefaults.Collections.Clients); await clients.DeleteManyAsync(Builders.Filter.Empty); } private async Task BuildMongoProviderAsync(FakeTimeProvider clock) { var services = new ServiceCollection(); services.AddSingleton(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(); var database = provider.GetRequiredService(); await initializer.InitialiseAsync(database, CancellationToken.None); return provider; } }