282 lines
11 KiB
C#
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;
|
|
}
|
|
}
|