feat: Initialize Zastava Webhook service with TLS and Authority authentication

- Added Program.cs to set up the web application with Serilog for logging, health check endpoints, and a placeholder admission endpoint.
- Configured Kestrel server to use TLS 1.3 and handle client certificates appropriately.
- Created StellaOps.Zastava.Webhook.csproj with necessary dependencies including Serilog and Polly.
- Documented tasks in TASKS.md for the Zastava Webhook project, outlining current work and exit criteria for each task.
This commit is contained in:
master
2025-10-19 18:36:22 +03:00
parent 2062da7a8b
commit d099a90f9b
966 changed files with 91038 additions and 1850 deletions

View File

@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.Mongo.Documents;
@@ -46,19 +47,19 @@ public class StandardClientProvisioningStoreTests
{
public Dictionary<string, AuthorityClientDocument> Documents { get; } = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Documents.TryGetValue(clientId, out var document);
return ValueTask.FromResult(document);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken)
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Documents[document.ClientId] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken)
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var removed = Documents.Remove(clientId);
return ValueTask.FromResult(removed);
@@ -69,16 +70,16 @@ public class StandardClientProvisioningStoreTests
{
public List<AuthorityRevocationDocument> Upserts { get; } = new();
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken)
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Upserts.Add(document);
return ValueTask.CompletedTask;
}
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken)
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(true);
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
}
}

View File

@@ -319,13 +319,13 @@ internal sealed class CapturingLoggerProvider : ILoggerProvider
internal sealed class StubRevocationStore : IAuthorityRevocationStore
{
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken)
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.CompletedTask;
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken)
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(false);
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
}
@@ -333,18 +333,18 @@ internal sealed class InMemoryClientStore : IAuthorityClientStore
{
private readonly Dictionary<string, AuthorityClientDocument> clients = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
clients.TryGetValue(clientId, out var document);
return ValueTask.FromResult(document);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken)
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
clients[document.ClientId] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken)
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(clients.Remove(clientId));
}

View File

@@ -9,4 +9,7 @@
<ProjectReference Include="..\StellaOps.Authority.Plugin.Standard\StellaOps.Authority.Plugin.Standard.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
</ItemGroup>
</Project>

View File

@@ -11,7 +11,7 @@
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<PackageReference Include="MongoDB.Driver" Version="2.22.0" />
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />

View File

@@ -60,6 +60,21 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
document.Properties[key] = value;
}
if (registration.Properties.TryGetValue(AuthorityClientMetadataKeys.SenderConstraint, out var senderConstraintRaw))
{
var normalizedConstraint = NormalizeSenderConstraint(senderConstraintRaw);
if (normalizedConstraint is not null)
{
document.SenderConstraint = normalizedConstraint;
document.Properties[AuthorityClientMetadataKeys.SenderConstraint] = normalizedConstraint;
}
else
{
document.SenderConstraint = null;
document.Properties.Remove(AuthorityClientMetadataKeys.SenderConstraint);
}
}
await clientStore.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
await revocationStore.RemoveAsync("client", registration.ClientId, cancellationToken).ConfigureAwait(false);
@@ -147,4 +162,20 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
return value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static string? NormalizeSenderConstraint(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim() switch
{
{ Length: 0 } => null,
var constraint when string.Equals(constraint, "dpop", StringComparison.OrdinalIgnoreCase) => "dpop",
var constraint when string.Equals(constraint, "mtls", StringComparison.OrdinalIgnoreCase) => "mtls",
_ => null
};
}
}

View File

@@ -9,4 +9,5 @@ public static class AuthorityClientMetadataKeys
public const string AllowedScopes = "allowedScopes";
public const string RedirectUris = "redirectUris";
public const string PostLogoutRedirectUris = "postLogoutRedirectUris";
public const string SenderConstraint = "senderConstraint";
}

View File

@@ -0,0 +1,45 @@
using MongoDB.Bson.Serialization.Attributes;
using System.Collections.Generic;
namespace StellaOps.Authority.Storage.Mongo.Documents;
/// <summary>
/// Captures certificate metadata associated with an mTLS-bound client.
/// </summary>
[BsonIgnoreExtraElements]
public sealed class AuthorityClientCertificateBinding
{
[BsonElement("thumbprint")]
public string Thumbprint { get; set; } = string.Empty;
[BsonElement("serialNumber")]
[BsonIgnoreIfNull]
public string? SerialNumber { get; set; }
[BsonElement("subject")]
[BsonIgnoreIfNull]
public string? Subject { get; set; }
[BsonElement("issuer")]
[BsonIgnoreIfNull]
public string? Issuer { get; set; }
[BsonElement("notBefore")]
public DateTimeOffset? NotBefore { get; set; }
[BsonElement("notAfter")]
public DateTimeOffset? NotAfter { get; set; }
[BsonElement("subjectAlternativeNames")]
public List<string> SubjectAlternativeNames { get; set; } = new();
[BsonElement("label")]
[BsonIgnoreIfNull]
public string? Label { get; set; }
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
[BsonElement("updatedAt")]
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}

View File

@@ -1,5 +1,6 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using System.Collections.Generic;
namespace StellaOps.Authority.Storage.Mongo.Documents;
@@ -50,6 +51,13 @@ public sealed class AuthorityClientDocument
[BsonIgnoreIfNull]
public string? Plugin { get; set; }
[BsonElement("senderConstraint")]
[BsonIgnoreIfNull]
public string? SenderConstraint { get; set; }
[BsonElement("certificateBindings")]
public List<AuthorityClientCertificateBinding> CertificateBindings { get; set; } = new();
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;

View File

@@ -7,6 +7,7 @@ using StellaOps.Authority.Storage.Mongo.Initialization;
using StellaOps.Authority.Storage.Mongo.Migrations;
using StellaOps.Authority.Storage.Mongo.Options;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.Mongo.Sessions;
namespace StellaOps.Authority.Storage.Mongo.Extensions;
@@ -56,6 +57,8 @@ public static class ServiceCollectionExtensions
services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthorityMongoMigration, EnsureAuthorityCollectionsMigration>());
services.AddScoped<IAuthorityMongoSessionAccessor, AuthorityMongoSessionAccessor>();
services.AddSingleton(static sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();

View File

@@ -16,7 +16,13 @@ internal sealed class AuthorityClientCollectionInitializer : IAuthorityCollectio
new CreateIndexOptions { Name = "client_id_unique", Unique = true }),
new CreateIndexModel<AuthorityClientDocument>(
Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.Disabled),
new CreateIndexOptions { Name = "client_disabled" })
new CreateIndexOptions { Name = "client_disabled" }),
new CreateIndexModel<AuthorityClientDocument>(
Builders<AuthorityClientDocument>.IndexKeys.Ascending(c => c.SenderConstraint),
new CreateIndexOptions { Name = "client_sender_constraint" }),
new CreateIndexModel<AuthorityClientDocument>(
Builders<AuthorityClientDocument>.IndexKeys.Ascending("certificateBindings.thumbprint"),
new CreateIndexOptions { Name = "client_cert_thumbprints" })
};
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);

View File

@@ -0,0 +1,128 @@
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using System;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Authority.Storage.Mongo.Options;
namespace StellaOps.Authority.Storage.Mongo.Sessions;
public interface IAuthorityMongoSessionAccessor : IAsyncDisposable
{
ValueTask<IClientSessionHandle> GetSessionAsync(CancellationToken cancellationToken = default);
}
internal sealed class AuthorityMongoSessionAccessor : IAuthorityMongoSessionAccessor
{
private readonly IMongoClient client;
private readonly AuthorityMongoOptions options;
private readonly object gate = new();
private Task<IClientSessionHandle>? sessionTask;
private IClientSessionHandle? session;
private bool disposed;
public AuthorityMongoSessionAccessor(
IMongoClient client,
IOptions<AuthorityMongoOptions> options)
{
this.client = client ?? throw new ArgumentNullException(nameof(client));
this.options = options?.Value ?? throw new ArgumentNullException(nameof(options));
}
public async ValueTask<IClientSessionHandle> GetSessionAsync(CancellationToken cancellationToken = default)
{
ObjectDisposedException.ThrowIf(disposed, this);
var existing = Volatile.Read(ref session);
if (existing is not null)
{
return existing;
}
Task<IClientSessionHandle> startTask;
lock (gate)
{
if (session is { } cached)
{
return cached;
}
sessionTask ??= StartSessionInternalAsync(cancellationToken);
startTask = sessionTask;
}
try
{
var handle = await startTask.WaitAsync(cancellationToken).ConfigureAwait(false);
if (session is null)
{
lock (gate)
{
if (session is null)
{
session = handle;
sessionTask = Task.FromResult(handle);
}
}
}
return handle;
}
catch
{
lock (gate)
{
if (ReferenceEquals(sessionTask, startTask))
{
sessionTask = null;
}
}
throw;
}
}
private async Task<IClientSessionHandle> StartSessionInternalAsync(CancellationToken cancellationToken)
{
var sessionOptions = new ClientSessionOptions
{
CausalConsistency = true,
DefaultTransactionOptions = new TransactionOptions(
readPreference: ReadPreference.Primary,
readConcern: ReadConcern.Majority,
writeConcern: WriteConcern.WMajority.With(wTimeout: options.CommandTimeout))
};
var handle = await client.StartSessionAsync(sessionOptions, cancellationToken).ConfigureAwait(false);
return handle;
}
public ValueTask DisposeAsync()
{
if (disposed)
{
return ValueTask.CompletedTask;
}
disposed = true;
IClientSessionHandle? handle;
lock (gate)
{
handle = session;
session = null;
sessionTask = null;
}
if (handle is not null)
{
handle.Dispose();
}
GC.SuppressFinalize(this);
return ValueTask.CompletedTask;
}
}

View File

@@ -7,7 +7,7 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="2.22.0" />
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />

View File

@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
@@ -12,11 +14,19 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
public AuthorityBootstrapInviteStore(IMongoCollection<AuthorityBootstrapInviteDocument> collection)
=> this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
public async ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken)
public async ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(document);
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
if (session is { })
{
await collection.InsertOneAsync(session, document, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
}
return document;
}
@@ -25,7 +35,8 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
string expectedType,
DateTimeOffset now,
string? reservedBy,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(token))
{
@@ -33,8 +44,9 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
}
var normalizedToken = token.Trim();
var tokenFilter = Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, normalizedToken);
var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.And(
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, normalizedToken),
tokenFilter,
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Pending));
var update = Builders<AuthorityBootstrapInviteDocument>.Update
@@ -47,14 +59,31 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
ReturnDocument = ReturnDocument.After
};
var invite = await collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken).ConfigureAwait(false);
AuthorityBootstrapInviteDocument? invite;
if (session is { })
{
invite = await collection.FindOneAndUpdateAsync(session, filter, update, options, cancellationToken).ConfigureAwait(false);
}
else
{
invite = await collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken).ConfigureAwait(false);
}
if (invite is null)
{
var existing = await collection
.Find(i => i.Token == normalizedToken)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
AuthorityBootstrapInviteDocument? existing;
if (session is { })
{
existing = await collection.Find(session, tokenFilter)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
}
else
{
existing = await collection.Find(tokenFilter)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
}
if (existing is null)
{
@@ -76,60 +105,76 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
if (!string.Equals(invite.Type, expectedType, StringComparison.OrdinalIgnoreCase))
{
await ReleaseAsync(normalizedToken, cancellationToken).ConfigureAwait(false);
await ReleaseAsync(normalizedToken, cancellationToken, session).ConfigureAwait(false);
return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, invite);
}
if (invite.ExpiresAt <= now)
{
await MarkExpiredAsync(normalizedToken, cancellationToken).ConfigureAwait(false);
await MarkExpiredAsync(normalizedToken, cancellationToken, session).ConfigureAwait(false);
return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.Expired, invite);
}
return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.Reserved, invite);
}
public async ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken)
public async ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(token))
{
return false;
}
var result = await collection.UpdateOneAsync(
Builders<AuthorityBootstrapInviteDocument>.Filter.And(
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token.Trim()),
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Reserved)),
Builders<AuthorityBootstrapInviteDocument>.Update
.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Pending)
.Set(i => i.ReservedAt, null)
.Set(i => i.ReservedBy, null),
cancellationToken: cancellationToken).ConfigureAwait(false);
var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.And(
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token.Trim()),
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Reserved));
var update = Builders<AuthorityBootstrapInviteDocument>.Update
.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Pending)
.Set(i => i.ReservedAt, null)
.Set(i => i.ReservedBy, null);
UpdateResult result;
if (session is { })
{
result = await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
return result.ModifiedCount > 0;
}
public async ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken)
public async ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(token))
{
return false;
}
var result = await collection.UpdateOneAsync(
Builders<AuthorityBootstrapInviteDocument>.Filter.And(
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token.Trim()),
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Reserved)),
Builders<AuthorityBootstrapInviteDocument>.Update
.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Consumed)
.Set(i => i.ConsumedAt, consumedAt)
.Set(i => i.ConsumedBy, consumedBy),
cancellationToken: cancellationToken).ConfigureAwait(false);
var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.And(
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token.Trim()),
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Reserved));
var update = Builders<AuthorityBootstrapInviteDocument>.Update
.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Consumed)
.Set(i => i.ConsumedAt, consumedAt)
.Set(i => i.ConsumedBy, consumedBy);
UpdateResult result;
if (session is { })
{
result = await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
return result.ModifiedCount > 0;
}
public async ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken)
public async ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.And(
Builders<AuthorityBootstrapInviteDocument>.Filter.Lte(i => i.ExpiresAt, now),
@@ -142,25 +187,49 @@ internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteS
.Set(i => i.ReservedAt, null)
.Set(i => i.ReservedBy, null);
var expired = await collection.Find(filter)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
List<AuthorityBootstrapInviteDocument> expired;
if (session is { })
{
expired = await collection.Find(session, filter)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
else
{
expired = await collection.Find(filter)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
if (expired.Count == 0)
{
return Array.Empty<AuthorityBootstrapInviteDocument>();
}
await collection.UpdateManyAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
if (session is { })
{
await collection.UpdateManyAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
await collection.UpdateManyAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
return expired;
}
private async Task MarkExpiredAsync(string token, CancellationToken cancellationToken)
private async Task MarkExpiredAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session)
{
await collection.UpdateOneAsync(
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token),
Builders<AuthorityBootstrapInviteDocument>.Update.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Expired),
cancellationToken: cancellationToken).ConfigureAwait(false);
var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token);
var update = Builders<AuthorityBootstrapInviteDocument>.Update.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Expired);
if (session is { })
{
await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
}

View File

@@ -1,5 +1,7 @@
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
@@ -20,7 +22,7 @@ internal sealed class AuthorityClientStore : IAuthorityClientStore
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
public async ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(clientId))
{
@@ -28,12 +30,15 @@ internal sealed class AuthorityClientStore : IAuthorityClientStore
}
var id = clientId.Trim();
return await collection.Find(c => c.ClientId == id)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
var filter = Builders<AuthorityClientDocument>.Filter.Eq(c => c.ClientId, id);
var cursor = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
return await cursor.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
public async ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken)
public async ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(document);
@@ -42,7 +47,15 @@ internal sealed class AuthorityClientStore : IAuthorityClientStore
var filter = Builders<AuthorityClientDocument>.Filter.Eq(c => c.ClientId, document.ClientId);
var options = new ReplaceOptions { IsUpsert = true };
var result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
ReplaceOneResult result;
if (session is { })
{
result = await collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
}
if (result.UpsertedId is not null)
{
@@ -50,7 +63,7 @@ internal sealed class AuthorityClientStore : IAuthorityClientStore
}
}
public async ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken)
public async ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(clientId))
{
@@ -58,7 +71,18 @@ internal sealed class AuthorityClientStore : IAuthorityClientStore
}
var id = clientId.Trim();
var result = await collection.DeleteOneAsync(c => c.ClientId == id, cancellationToken).ConfigureAwait(false);
var filter = Builders<AuthorityClientDocument>.Filter.Eq(c => c.ClientId, id);
DeleteResult result;
if (session is { })
{
result = await collection.DeleteOneAsync(session, filter, options: null, cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
}
return result.DeletedCount > 0;
}
}

View File

@@ -1,5 +1,9 @@
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
@@ -17,11 +21,19 @@ internal sealed class AuthorityLoginAttemptStore : IAuthorityLoginAttemptStore
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken)
public async ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(document);
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
if (session is { })
{
await collection.InsertOneAsync(session, document, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
}
logger.LogDebug(
"Recorded authority audit event {EventType} for subject '{SubjectId}' with outcome {Outcome}.",
document.EventType,
@@ -29,7 +41,7 @@ internal sealed class AuthorityLoginAttemptStore : IAuthorityLoginAttemptStore
document.Outcome);
}
public async ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken)
public async ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(subjectId) || limit <= 0)
{
@@ -38,14 +50,22 @@ internal sealed class AuthorityLoginAttemptStore : IAuthorityLoginAttemptStore
var normalized = subjectId.Trim();
var cursor = await collection.FindAsync(
Builders<AuthorityLoginAttemptDocument>.Filter.Eq(a => a.SubjectId, normalized),
new FindOptions<AuthorityLoginAttemptDocument>
{
Sort = Builders<AuthorityLoginAttemptDocument>.Sort.Descending(a => a.OccurredAt),
Limit = limit
},
cancellationToken).ConfigureAwait(false);
var filter = Builders<AuthorityLoginAttemptDocument>.Filter.Eq(a => a.SubjectId, normalized);
var options = new FindOptions<AuthorityLoginAttemptDocument>
{
Sort = Builders<AuthorityLoginAttemptDocument>.Sort.Descending(a => a.OccurredAt),
Limit = limit
};
IAsyncCursor<AuthorityLoginAttemptDocument> cursor;
if (session is { })
{
cursor = await collection.FindAsync(session, filter, options, cancellationToken).ConfigureAwait(false);
}
else
{
cursor = await collection.FindAsync(filter, options, cancellationToken).ConfigureAwait(false);
}
return await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
}

View File

@@ -22,10 +22,14 @@ internal sealed class AuthorityRevocationExportStateStore : IAuthorityRevocation
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken)
public async ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var filter = Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Id, StateId);
return await collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
var query = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
public async ValueTask<AuthorityRevocationExportStateDocument> UpdateAsync(
@@ -33,7 +37,8 @@ internal sealed class AuthorityRevocationExportStateStore : IAuthorityRevocation
long newSequence,
string bundleId,
DateTimeOffset issuedAt,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
IClientSessionHandle? session = null)
{
if (newSequence <= 0)
{
@@ -66,7 +71,16 @@ internal sealed class AuthorityRevocationExportStateStore : IAuthorityRevocation
try
{
var result = await collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken).ConfigureAwait(false);
AuthorityRevocationExportStateDocument? result;
if (session is { })
{
result = await collection.FindOneAndUpdateAsync(session, filter, update, options, cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken).ConfigureAwait(false);
}
if (result is null)
{
throw new InvalidOperationException("Revocation export state update conflict.");

View File

@@ -22,7 +22,7 @@ internal sealed class AuthorityRevocationStore : IAuthorityRevocationStore
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken)
public async ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(document);
@@ -48,10 +48,10 @@ internal sealed class AuthorityRevocationStore : IAuthorityRevocationStore
var now = DateTimeOffset.UtcNow;
document.UpdatedAt = now;
var existing = await collection
.Find(filter)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
var query = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
var existing = await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (existing is null)
{
@@ -63,11 +63,19 @@ internal sealed class AuthorityRevocationStore : IAuthorityRevocationStore
document.CreatedAt = existing.CreatedAt;
}
await collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
var options = new ReplaceOptions { IsUpsert = true };
if (session is { })
{
await collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
}
else
{
await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
}
logger.LogDebug("Upserted Authority revocation entry {Category}:{RevocationId}.", document.Category, document.RevocationId);
}
public async ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken)
public async ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(revocationId))
{
@@ -78,7 +86,15 @@ internal sealed class AuthorityRevocationStore : IAuthorityRevocationStore
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.Category, category.Trim()),
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.RevocationId, revocationId.Trim()));
var result = await collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
DeleteResult result;
if (session is { })
{
result = await collection.DeleteOneAsync(session, filter, options: null, cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.DeleteOneAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false);
}
if (result.DeletedCount > 0)
{
logger.LogInformation("Removed Authority revocation entry {Category}:{RevocationId}.", category, revocationId);
@@ -88,14 +104,17 @@ internal sealed class AuthorityRevocationStore : IAuthorityRevocationStore
return false;
}
public async ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
public async ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var filter = Builders<AuthorityRevocationDocument>.Filter.Or(
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.ExpiresAt, null),
Builders<AuthorityRevocationDocument>.Filter.Gt(d => d.ExpiresAt, asOf));
var documents = await collection
.Find(filter)
var query = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
var documents = await query
.Sort(Builders<AuthorityRevocationDocument>.Sort.Ascending(d => d.Category).Ascending(d => d.RevocationId))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);

View File

@@ -1,5 +1,8 @@
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
@@ -20,7 +23,7 @@ internal sealed class AuthorityScopeStore : IAuthorityScopeStore
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<AuthorityScopeDocument?> FindByNameAsync(string name, CancellationToken cancellationToken)
public async ValueTask<AuthorityScopeDocument?> FindByNameAsync(string name, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(name))
{
@@ -28,18 +31,30 @@ internal sealed class AuthorityScopeStore : IAuthorityScopeStore
}
var normalized = name.Trim();
return await collection.Find(s => s.Name == normalized)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
var filter = Builders<AuthorityScopeDocument>.Filter.Eq(s => s.Name, normalized);
var query = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
public async ValueTask<IReadOnlyList<AuthorityScopeDocument>> ListAsync(CancellationToken cancellationToken)
public async ValueTask<IReadOnlyList<AuthorityScopeDocument>> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var cursor = await collection.FindAsync(FilterDefinition<AuthorityScopeDocument>.Empty, cancellationToken: cancellationToken).ConfigureAwait(false);
IAsyncCursor<AuthorityScopeDocument> cursor;
if (session is { })
{
cursor = await collection.FindAsync(session, FilterDefinition<AuthorityScopeDocument>.Empty, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
cursor = await collection.FindAsync(FilterDefinition<AuthorityScopeDocument>.Empty, cancellationToken: cancellationToken).ConfigureAwait(false);
}
return await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
}
public async ValueTask UpsertAsync(AuthorityScopeDocument document, CancellationToken cancellationToken)
public async ValueTask UpsertAsync(AuthorityScopeDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(document);
@@ -48,14 +63,23 @@ internal sealed class AuthorityScopeStore : IAuthorityScopeStore
var filter = Builders<AuthorityScopeDocument>.Filter.Eq(s => s.Name, document.Name);
var options = new ReplaceOptions { IsUpsert = true };
var result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
ReplaceOneResult result;
if (session is { })
{
result = await collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
}
if (result.UpsertedId is not null)
{
logger.LogInformation("Inserted Authority scope {ScopeName}.", document.Name);
}
}
public async ValueTask<bool> DeleteByNameAsync(string name, CancellationToken cancellationToken)
public async ValueTask<bool> DeleteByNameAsync(string name, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(name))
{
@@ -63,7 +87,18 @@ internal sealed class AuthorityScopeStore : IAuthorityScopeStore
}
var normalized = name.Trim();
var result = await collection.DeleteOneAsync(s => s.Name == normalized, cancellationToken).ConfigureAwait(false);
var filter = Builders<AuthorityScopeDocument>.Filter.Eq(s => s.Name, normalized);
DeleteResult result;
if (session is { })
{
result = await collection.DeleteOneAsync(session, filter, options: null, cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.DeleteOneAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false);
}
return result.DeletedCount > 0;
}
}

View File

@@ -1,10 +1,12 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using MongoDB.Bson;
using MongoDB.Driver;
using System.Linq;
using System.Globalization;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
@@ -22,15 +24,23 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken)
public async ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(document);
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
if (session is { })
{
await collection.InsertOneAsync(session, document, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
}
logger.LogDebug("Inserted Authority token {TokenId}.", document.TokenId);
}
public async ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken)
public async ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(tokenId))
{
@@ -38,12 +48,15 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
}
var id = tokenId.Trim();
return await collection.Find(t => t.TokenId == id)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, id);
var query = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
public async ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken)
public async ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(referenceId))
{
@@ -51,9 +64,12 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
}
var id = referenceId.Trim();
return await collection.Find(t => t.ReferenceId == id)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.ReferenceId, id);
var query = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
public async ValueTask UpdateStatusAsync(
@@ -63,7 +79,8 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
string? reason,
string? reasonDescription,
IReadOnlyDictionary<string, string?>? metadata,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(tokenId))
{
@@ -82,16 +99,29 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
.Set(t => t.RevokedReasonDescription, reasonDescription)
.Set(t => t.RevokedMetadata, metadata is null ? null : new Dictionary<string, string?>(metadata, StringComparer.OrdinalIgnoreCase));
var result = await collection.UpdateOneAsync(
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, tokenId.Trim()),
update,
cancellationToken: cancellationToken).ConfigureAwait(false);
var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, tokenId.Trim());
UpdateResult result;
if (session is { })
{
result = await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
logger.LogDebug("Updated token {TokenId} status to {Status} (matched {Matched}).", tokenId, status, result.MatchedCount);
}
public async ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken)
public async ValueTask<TokenUsageUpdateResult> RecordUsageAsync(
string tokenId,
string? remoteAddress,
string? userAgent,
DateTimeOffset observedAt,
CancellationToken cancellationToken,
IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(tokenId))
{
@@ -104,10 +134,11 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
}
var id = tokenId.Trim();
var token = await collection
.Find(t => t.TokenId == id)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, id);
var query = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
var token = await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (token is null)
{
@@ -147,10 +178,14 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
}
var update = Builders<AuthorityTokenDocument>.Update.Set(t => t.Devices, token.Devices);
await collection.UpdateOneAsync(
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, id),
update,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (session is { })
{
await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false);
}
return new TokenUsageUpdateResult(suspicious ? TokenUsageUpdateStatus.SuspectedReplay : TokenUsageUpdateStatus.Recorded, normalizedAddress, normalizedAgent);
}
@@ -170,14 +205,22 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
};
}
public async ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
public async ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var filter = Builders<AuthorityTokenDocument>.Filter.And(
Builders<AuthorityTokenDocument>.Filter.Not(
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Status, "revoked")),
Builders<AuthorityTokenDocument>.Filter.Lt(t => t.ExpiresAt, threshold));
var result = await collection.DeleteManyAsync(filter, cancellationToken).ConfigureAwait(false);
DeleteResult result;
if (session is { })
{
result = await collection.DeleteManyAsync(session, filter, options: null, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.DeleteManyAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false);
}
if (result.DeletedCount > 0)
{
logger.LogInformation("Deleted {Count} expired Authority tokens.", result.DeletedCount);
@@ -186,7 +229,7 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
return result.DeletedCount;
}
public async ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken)
public async ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Status, "revoked");
@@ -197,8 +240,11 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
Builders<AuthorityTokenDocument>.Filter.Gt(t => t.RevokedAt, threshold));
}
var documents = await collection
.Find(filter)
var query = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
var documents = await query
.Sort(Builders<AuthorityTokenDocument>.Sort.Ascending(t => t.RevokedAt).Ascending(t => t.TokenId))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);

View File

@@ -1,5 +1,7 @@
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
@@ -20,20 +22,23 @@ internal sealed class AuthorityUserStore : IAuthorityUserStore
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<AuthorityUserDocument?> FindBySubjectIdAsync(string subjectId, CancellationToken cancellationToken)
public async ValueTask<AuthorityUserDocument?> FindBySubjectIdAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(subjectId))
{
return null;
}
return await collection
.Find(u => u.SubjectId == subjectId)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
var normalized = subjectId.Trim();
var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.SubjectId, normalized);
var query = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
public async ValueTask<AuthorityUserDocument?> FindByNormalizedUsernameAsync(string normalizedUsername, CancellationToken cancellationToken)
public async ValueTask<AuthorityUserDocument?> FindByNormalizedUsernameAsync(string normalizedUsername, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(normalizedUsername))
{
@@ -42,13 +47,15 @@ internal sealed class AuthorityUserStore : IAuthorityUserStore
var normalised = normalizedUsername.Trim();
return await collection
.Find(u => u.NormalizedUsername == normalised)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.NormalizedUsername, normalised);
var query = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
return await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
public async ValueTask UpsertAsync(AuthorityUserDocument document, CancellationToken cancellationToken)
public async ValueTask UpsertAsync(AuthorityUserDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(document);
@@ -57,9 +64,15 @@ internal sealed class AuthorityUserStore : IAuthorityUserStore
var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.SubjectId, document.SubjectId);
var options = new ReplaceOptions { IsUpsert = true };
var result = await collection
.ReplaceOneAsync(filter, document, options, cancellationToken)
.ConfigureAwait(false);
ReplaceOneResult result;
if (session is { })
{
result = await collection.ReplaceOneAsync(session, filter, document, options, cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
}
if (result.UpsertedId is not null)
{
@@ -67,7 +80,7 @@ internal sealed class AuthorityUserStore : IAuthorityUserStore
}
}
public async ValueTask<bool> DeleteBySubjectIdAsync(string subjectId, CancellationToken cancellationToken)
public async ValueTask<bool> DeleteBySubjectIdAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(subjectId))
{
@@ -75,7 +88,18 @@ internal sealed class AuthorityUserStore : IAuthorityUserStore
}
var normalised = subjectId.Trim();
var result = await collection.DeleteOneAsync(u => u.SubjectId == normalised, cancellationToken).ConfigureAwait(false);
var filter = Builders<AuthorityUserDocument>.Filter.Eq(u => u.SubjectId, normalised);
DeleteResult result;
if (session is { })
{
result = await collection.DeleteOneAsync(session, filter, options: null, cancellationToken).ConfigureAwait(false);
}
else
{
result = await collection.DeleteOneAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false);
}
return result.DeletedCount > 0;
}
}

View File

@@ -1,18 +1,19 @@
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
public interface IAuthorityBootstrapInviteStore
{
ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken);
ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<BootstrapInviteReservationResult> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken);
ValueTask<BootstrapInviteReservationResult> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken);
ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken);
ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken);
ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}
public enum BootstrapInviteReservationStatus

View File

@@ -1,12 +1,13 @@
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
public interface IAuthorityClientStore
{
ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken);
ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken);
ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken);
ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}

View File

@@ -1,10 +1,11 @@
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
public interface IAuthorityLoginAttemptStore
{
ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken);
ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken);
ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}

View File

@@ -1,18 +1,20 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
public interface IAuthorityRevocationExportStateStore
{
ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken);
ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<AuthorityRevocationExportStateDocument> UpdateAsync(
long expectedSequence,
long newSequence,
string bundleId,
DateTimeOffset issuedAt,
CancellationToken cancellationToken);
CancellationToken cancellationToken,
IClientSessionHandle? session = null);
}

View File

@@ -2,15 +2,16 @@ using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
public interface IAuthorityRevocationStore
{
ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken);
ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken);
ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken);
ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}

View File

@@ -1,14 +1,15 @@
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
public interface IAuthorityScopeStore
{
ValueTask<AuthorityScopeDocument?> FindByNameAsync(string name, CancellationToken cancellationToken);
ValueTask<AuthorityScopeDocument?> FindByNameAsync(string name, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityScopeDocument>> ListAsync(CancellationToken cancellationToken);
ValueTask<IReadOnlyList<AuthorityScopeDocument>> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask UpsertAsync(AuthorityScopeDocument document, CancellationToken cancellationToken);
ValueTask UpsertAsync(AuthorityScopeDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<bool> DeleteByNameAsync(string name, CancellationToken cancellationToken);
ValueTask<bool> DeleteByNameAsync(string name, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}

View File

@@ -1,16 +1,17 @@
using System;
using System.Collections.Generic;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
public interface IAuthorityTokenStore
{
ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken);
ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken);
ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken);
ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask UpdateStatusAsync(
string tokenId,
@@ -19,13 +20,14 @@ public interface IAuthorityTokenStore
string? reason,
string? reasonDescription,
IReadOnlyDictionary<string, string?>? metadata,
CancellationToken cancellationToken);
CancellationToken cancellationToken,
IClientSessionHandle? session = null);
ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken);
ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken);
ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken);
ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}
public enum TokenUsageUpdateStatus

View File

@@ -1,14 +1,15 @@
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
public interface IAuthorityUserStore
{
ValueTask<AuthorityUserDocument?> FindBySubjectIdAsync(string subjectId, CancellationToken cancellationToken);
ValueTask<AuthorityUserDocument?> FindBySubjectIdAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<AuthorityUserDocument?> FindByNormalizedUsernameAsync(string normalizedUsername, CancellationToken cancellationToken);
ValueTask<AuthorityUserDocument?> FindByNormalizedUsernameAsync(string normalizedUsername, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask UpsertAsync(AuthorityUserDocument document, CancellationToken cancellationToken);
ValueTask UpsertAsync(AuthorityUserDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<bool> DeleteBySubjectIdAsync(string subjectId, CancellationToken cancellationToken);
ValueTask<bool> DeleteBySubjectIdAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
}

View File

@@ -8,6 +8,7 @@ using Microsoft.Extensions.Time.Testing;
using StellaOps.Authority.Bootstrap;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using MongoDB.Driver;
using StellaOps.Cryptography.Audit;
using Xunit;
@@ -65,19 +66,19 @@ public sealed class BootstrapInviteCleanupServiceTests
public bool ExpireCalled { get; private set; }
public ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken)
public ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> throw new NotImplementedException();
public ValueTask<BootstrapInviteReservationResult> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken)
public ValueTask<BootstrapInviteReservationResult> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, null));
public ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken)
public ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(false);
public ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken)
public ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(false);
public ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken)
public ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ExpireCalled = true;
return ValueTask.FromResult(invites);

View File

@@ -13,11 +13,13 @@ using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.OpenIddict.Handlers;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.RateLimiting;
using StellaOps.Cryptography.Audit;
using Xunit;
using MongoDB.Bson;
using MongoDB.Driver;
using static StellaOps.Authority.Tests.OpenIddict.TestHelpers;
namespace StellaOps.Authority.Tests.OpenIddict;
@@ -127,6 +129,7 @@ public class ClientCredentialsHandlersTests
var descriptor = CreateDescriptor(clientDocument);
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: descriptor);
var tokenStore = new TestTokenStore();
var sessionAccessor = new NullMongoSessionAccessor();
var authSink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var validateHandler = new ValidateClientCredentialsHandler(
@@ -148,10 +151,11 @@ public class ClientCredentialsHandlersTests
var handler = new HandleClientCredentialsHandler(
registry,
tokenStore,
sessionAccessor,
TimeProvider.System,
TestActivitySource,
NullLogger<HandleClientCredentialsHandler>.Instance);
var persistHandler = new PersistTokensHandler(tokenStore, TimeProvider.System, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);
var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, TimeProvider.System, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);
var context = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
@@ -202,8 +206,10 @@ public class TokenValidationHandlersTests
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var auditSink = new TestAuthEventSink();
var sessionAccessor = new NullMongoSessionAccessor();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessor,
new TestClientStore(CreateClient()),
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(CreateClient())),
metadataAccessor,
@@ -248,8 +254,10 @@ public class TokenValidationHandlersTests
var metadataAccessorSuccess = new TestRateLimiterMetadataAccessor();
var auditSinkSuccess = new TestAuthEventSink();
var sessionAccessor = new NullMongoSessionAccessor();
var handler = new ValidateAccessTokenHandler(
new TestTokenStore(),
sessionAccessor,
new TestClientStore(clientDocument),
registry,
metadataAccessorSuccess,
@@ -313,8 +321,10 @@ public class TokenValidationHandlersTests
clientDocument.ClientId = "agent";
var auditSink = new TestAuthEventSink();
var registry = CreateRegistry(withClientProvisioning: false, clientDescriptor: null);
var sessionAccessorReplay = new NullMongoSessionAccessor();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessorReplay,
new TestClientStore(clientDocument),
registry,
metadataAccessor,
@@ -360,19 +370,19 @@ internal sealed class TestClientStore : IAuthorityClientStore
}
}
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
clients.TryGetValue(clientId, out var document);
return ValueTask.FromResult(document);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken)
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
clients[document.ClientId] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken)
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(clients.Remove(clientId));
}
@@ -382,28 +392,28 @@ internal sealed class TestTokenStore : IAuthorityTokenStore
public Func<string?, string?, TokenUsageUpdateResult>? UsageCallback { get; set; }
public ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken)
public ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Inserted = document;
return ValueTask.CompletedTask;
}
public ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken)
public ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(Inserted is not null && string.Equals(Inserted.TokenId, tokenId, StringComparison.OrdinalIgnoreCase) ? Inserted : null);
public ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken)
public ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<AuthorityTokenDocument?>(null);
public ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, string? reason, string? reasonDescription, IReadOnlyDictionary<string, string?>? metadata, CancellationToken cancellationToken)
public ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, string? reason, string? reasonDescription, IReadOnlyDictionary<string, string?>? metadata, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.CompletedTask;
public ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
public ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(0L);
public ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken)
public ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(UsageCallback?.Invoke(remoteAddress, userAgent) ?? new TokenUsageUpdateResult(TokenUsageUpdateStatus.Recorded, remoteAddress, userAgent));
public ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken)
public ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityTokenDocument>>(Array.Empty<AuthorityTokenDocument>());
}
@@ -516,6 +526,14 @@ internal sealed class TestRateLimiterMetadataAccessor : IAuthorityRateLimiterMet
public void SetTag(string name, string? value) => metadata.SetTag(name, value);
}
internal sealed class NullMongoSessionAccessor : IAuthorityMongoSessionAccessor
{
public ValueTask<IClientSessionHandle> GetSessionAsync(CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IClientSessionHandle>(null!);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
internal static class TestHelpers
{
public static AuthorityClientDocument CreateClient(

View File

@@ -16,6 +16,7 @@ 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.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Concelier.Testing;
using StellaOps.Authority.RateLimiting;
@@ -59,9 +60,11 @@ public sealed class TokenPersistenceIntegrationTests
var authSink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
await using var scope = provider.CreateAsyncScope();
var sessionAccessor = scope.ServiceProvider.GetRequiredService<IAuthorityMongoSessionAccessor>();
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 handleHandler = new HandleClientCredentialsHandler(registry, tokenStore, sessionAccessor, clock, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance);
var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, clock, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);
var transaction = TestHelpers.CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:trigger");
transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(15);
@@ -151,8 +154,11 @@ public sealed class TokenPersistenceIntegrationTests
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var auditSink = new TestAuthEventSink();
await using var scope = provider.CreateAsyncScope();
var sessionAccessor = scope.ServiceProvider.GetRequiredService<IAuthorityMongoSessionAccessor>();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessor,
clientStore,
registry,
metadataAccessor,
@@ -249,6 +255,107 @@ public sealed class TokenPersistenceIntegrationTests
});
}
[Fact]
public async Task MongoSessions_ProvideReadYourWriteAfterPrimaryElection()
{
await ResetCollectionsAsync();
var clock = new FakeTimeProvider(DateTimeOffset.UtcNow);
await using var provider = await BuildMongoProviderAsync(clock);
var tokenStore = provider.GetRequiredService<IAuthorityTokenStore>();
await using var scope = provider.CreateAsyncScope();
var sessionAccessor = scope.ServiceProvider.GetRequiredService<IAuthorityMongoSessionAccessor>();
var session = await sessionAccessor.GetSessionAsync(CancellationToken.None);
var tokenId = $"election-token-{Guid.NewGuid():N}";
var document = new AuthorityTokenDocument
{
TokenId = tokenId,
Type = OpenIddictConstants.TokenTypeHints.AccessToken,
SubjectId = "session-subject",
ClientId = "session-client",
Scope = new List<string> { "jobs:read" },
Status = "valid",
CreatedAt = clock.GetUtcNow(),
ExpiresAt = clock.GetUtcNow().AddMinutes(30)
};
await tokenStore.InsertAsync(document, CancellationToken.None, session);
await StepDownPrimaryAsync(fixture.Client, CancellationToken.None);
AuthorityTokenDocument? fetched = null;
for (var attempt = 0; attempt < 5; attempt++)
{
try
{
fetched = await tokenStore.FindByTokenIdAsync(tokenId, CancellationToken.None, session);
if (fetched is not null)
{
break;
}
}
catch (MongoException)
{
await Task.Delay(250);
}
}
Assert.NotNull(fetched);
Assert.Equal(tokenId, fetched!.TokenId);
}
private static async Task StepDownPrimaryAsync(IMongoClient client, CancellationToken cancellationToken)
{
var admin = client.GetDatabase("admin");
try
{
var command = new BsonDocument
{
{ "replSetStepDown", 5 },
{ "force", true }
};
await admin.RunCommandAsync<BsonDocument>(command, cancellationToken: cancellationToken);
}
catch (MongoCommandException)
{
// Expected when the current primary steps down.
}
catch (MongoConnectionException)
{
// Connection may drop during election; ignore and continue.
}
await WaitForPrimaryAsync(admin, cancellationToken);
}
private static async Task WaitForPrimaryAsync(IMongoDatabase adminDatabase, CancellationToken cancellationToken)
{
for (var attempt = 0; attempt < 40; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var status = await adminDatabase.RunCommandAsync<BsonDocument>(new BsonDocument { { "replSetGetStatus", 1 } }, cancellationToken: cancellationToken);
if (status.TryGetValue("myState", out var state) && state.ToInt32() == 1)
{
return;
}
}
catch (MongoCommandException)
{
// Ignore intermediate states and retry.
}
await Task.Delay(250, cancellationToken);
}
throw new TimeoutException("Replica set primary election did not complete in time.");
}
private async Task ResetCollectionsAsync()
{
var tokens = fixture.Database.GetCollection<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens);

View File

@@ -9,4 +9,7 @@
<ProjectReference Include="..\StellaOps.Authority\StellaOps.Authority.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
</ItemGroup>
</Project>

View File

@@ -55,6 +55,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Test
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.DependencyInjection", "..\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj", "{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Security", "..\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj", "{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -377,6 +379,18 @@ Global
{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Release|x64.Build.0 = Release|Any CPU
{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Release|x86.ActiveCfg = Release|Any CPU
{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Release|x86.Build.0 = Release|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Debug|x64.ActiveCfg = Debug|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Debug|x64.Build.0 = Debug|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Debug|x86.ActiveCfg = Debug|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Debug|x86.Build.0 = Debug|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Release|Any CPU.Build.0 = Release|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Release|x64.ActiveCfg = Release|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Release|x64.Build.0 = Release|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Release|x86.ActiveCfg = Release|Any CPU
{ACEFD2D2-D4B9-47FB-91F2-1EA94C28D93C}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@@ -10,10 +10,12 @@ using OpenIddict.Abstractions;
using OpenIddict.Extensions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using MongoDB.Driver;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.RateLimiting;
using StellaOps.Cryptography.Audit;
@@ -237,6 +239,7 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
{
private readonly IAuthorityIdentityProviderRegistry registry;
private readonly IAuthorityTokenStore tokenStore;
private readonly IAuthorityMongoSessionAccessor sessionAccessor;
private readonly TimeProvider clock;
private readonly ActivitySource activitySource;
private readonly ILogger<HandleClientCredentialsHandler> logger;
@@ -244,12 +247,14 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
public HandleClientCredentialsHandler(
IAuthorityIdentityProviderRegistry registry,
IAuthorityTokenStore tokenStore,
IAuthorityMongoSessionAccessor sessionAccessor,
TimeProvider clock,
ActivitySource activitySource,
ILogger<HandleClientCredentialsHandler> logger)
{
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
this.sessionAccessor = sessionAccessor ?? throw new ArgumentNullException(nameof(sessionAccessor));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -339,7 +344,8 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
await provider.ClaimsEnricher.EnrichAsync(identity, enrichmentContext, context.CancellationToken).ConfigureAwait(false);
}
await PersistTokenAsync(context, document, tokenId, grantedScopes, activity).ConfigureAwait(false);
var session = await sessionAccessor.GetSessionAsync(context.CancellationToken).ConfigureAwait(false);
await PersistTokenAsync(context, document, tokenId, grantedScopes, session, activity).ConfigureAwait(false);
context.Principal = principal;
context.HandleRequest();
@@ -388,6 +394,7 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
AuthorityClientDocument document,
string tokenId,
IReadOnlyCollection<string> scopes,
IClientSessionHandle session,
Activity? activity)
{
if (context.IsRejected)
@@ -413,7 +420,7 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
ExpiresAt = expiresAt
};
await tokenStore.InsertAsync(record, context.CancellationToken).ConfigureAwait(false);
await tokenStore.InsertAsync(record, context.CancellationToken, session).ConfigureAwait(false);
context.Transaction.Properties[AuthorityOpenIddictConstants.TokenTransactionProperty] = record;
activity?.SetTag("authority.token_id", tokenId);
}

View File

@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
namespace StellaOps.Authority.OpenIddict.Handlers;
@@ -13,17 +14,20 @@ namespace StellaOps.Authority.OpenIddict.Handlers;
internal sealed class HandleRevocationRequestHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleRevocationRequestContext>
{
private readonly IAuthorityTokenStore tokenStore;
private readonly IAuthorityMongoSessionAccessor sessionAccessor;
private readonly TimeProvider clock;
private readonly ILogger<HandleRevocationRequestHandler> logger;
private readonly ActivitySource activitySource;
public HandleRevocationRequestHandler(
IAuthorityTokenStore tokenStore,
IAuthorityMongoSessionAccessor sessionAccessor,
TimeProvider clock,
ActivitySource activitySource,
ILogger<HandleRevocationRequestHandler> logger)
{
this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
this.sessionAccessor = sessionAccessor ?? throw new ArgumentNullException(nameof(sessionAccessor));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -43,14 +47,15 @@ internal sealed class HandleRevocationRequestHandler : IOpenIddictServerHandler<
}
var token = request.Token.Trim();
var document = await tokenStore.FindByTokenIdAsync(token, context.CancellationToken).ConfigureAwait(false);
var session = await sessionAccessor.GetSessionAsync(context.CancellationToken).ConfigureAwait(false);
var document = await tokenStore.FindByTokenIdAsync(token, context.CancellationToken, session).ConfigureAwait(false);
if (document is null)
{
var tokenId = TryExtractTokenId(token);
if (!string.IsNullOrWhiteSpace(tokenId))
{
document = await tokenStore.FindByTokenIdAsync(tokenId!, context.CancellationToken).ConfigureAwait(false);
document = await tokenStore.FindByTokenIdAsync(tokenId!, context.CancellationToken, session).ConfigureAwait(false);
}
}
@@ -70,7 +75,8 @@ internal sealed class HandleRevocationRequestHandler : IOpenIddictServerHandler<
"client_request",
null,
null,
context.CancellationToken).ConfigureAwait(false);
context.CancellationToken,
session).ConfigureAwait(false);
logger.LogInformation("Token {TokenId} revoked via revocation endpoint.", document.TokenId);
activity?.SetTag("authority.token_id", document.TokenId);

View File

@@ -10,7 +10,9 @@ using Microsoft.Extensions.Logging;
using OpenIddict.Abstractions;
using OpenIddict.Extensions;
using OpenIddict.Server;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
namespace StellaOps.Authority.OpenIddict.Handlers;
@@ -18,17 +20,20 @@ namespace StellaOps.Authority.OpenIddict.Handlers;
internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ProcessSignInContext>
{
private readonly IAuthorityTokenStore tokenStore;
private readonly IAuthorityMongoSessionAccessor sessionAccessor;
private readonly TimeProvider clock;
private readonly ActivitySource activitySource;
private readonly ILogger<PersistTokensHandler> logger;
public PersistTokensHandler(
IAuthorityTokenStore tokenStore,
IAuthorityMongoSessionAccessor sessionAccessor,
TimeProvider clock,
ActivitySource activitySource,
ILogger<PersistTokensHandler> logger)
{
this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
this.sessionAccessor = sessionAccessor ?? throw new ArgumentNullException(nameof(sessionAccessor));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -47,30 +52,31 @@ internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddict
}
using var activity = activitySource.StartActivity("authority.token.persist", ActivityKind.Internal);
var session = await sessionAccessor.GetSessionAsync(context.CancellationToken).ConfigureAwait(false);
var issuedAt = clock.GetUtcNow();
if (context.AccessTokenPrincipal is ClaimsPrincipal accessPrincipal)
{
await PersistAsync(accessPrincipal, OpenIddictConstants.TokenTypeHints.AccessToken, issuedAt, context.CancellationToken).ConfigureAwait(false);
await PersistAsync(accessPrincipal, OpenIddictConstants.TokenTypeHints.AccessToken, issuedAt, session, context.CancellationToken).ConfigureAwait(false);
}
if (context.RefreshTokenPrincipal is ClaimsPrincipal refreshPrincipal)
{
await PersistAsync(refreshPrincipal, OpenIddictConstants.TokenTypeHints.RefreshToken, issuedAt, context.CancellationToken).ConfigureAwait(false);
await PersistAsync(refreshPrincipal, OpenIddictConstants.TokenTypeHints.RefreshToken, issuedAt, session, context.CancellationToken).ConfigureAwait(false);
}
if (context.AuthorizationCodePrincipal is ClaimsPrincipal authorizationPrincipal)
{
await PersistAsync(authorizationPrincipal, OpenIddictConstants.TokenTypeHints.AuthorizationCode, issuedAt, context.CancellationToken).ConfigureAwait(false);
await PersistAsync(authorizationPrincipal, OpenIddictConstants.TokenTypeHints.AuthorizationCode, issuedAt, session, context.CancellationToken).ConfigureAwait(false);
}
if (context.DeviceCodePrincipal is ClaimsPrincipal devicePrincipal)
{
await PersistAsync(devicePrincipal, OpenIddictConstants.TokenTypeHints.DeviceCode, issuedAt, context.CancellationToken).ConfigureAwait(false);
await PersistAsync(devicePrincipal, OpenIddictConstants.TokenTypeHints.DeviceCode, issuedAt, session, context.CancellationToken).ConfigureAwait(false);
}
}
private async ValueTask PersistAsync(ClaimsPrincipal principal, string tokenType, DateTimeOffset issuedAt, CancellationToken cancellationToken)
private async ValueTask PersistAsync(ClaimsPrincipal principal, string tokenType, DateTimeOffset issuedAt, IClientSessionHandle session, CancellationToken cancellationToken)
{
var tokenId = EnsureTokenId(principal);
var scopes = ExtractScopes(principal);
@@ -88,7 +94,7 @@ internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddict
try
{
await tokenStore.InsertAsync(document, cancellationToken).ConfigureAwait(false);
await tokenStore.InsertAsync(document, cancellationToken, session).ConfigureAwait(false);
logger.LogDebug("Persisted {Type} token {TokenId} for client {ClientId}.", tokenType, tokenId, document.ClientId ?? "<none>");
}
catch (Exception ex)

View File

@@ -8,10 +8,12 @@ using OpenIddict.Abstractions;
using OpenIddict.Extensions;
using OpenIddict.Server;
using StellaOps.Auth.Abstractions;
using MongoDB.Driver;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.RateLimiting;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Cryptography.Audit;
@@ -20,6 +22,7 @@ namespace StellaOps.Authority.OpenIddict.Handlers;
internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ValidateTokenContext>
{
private readonly IAuthorityTokenStore tokenStore;
private readonly IAuthorityMongoSessionAccessor sessionAccessor;
private readonly IAuthorityClientStore clientStore;
private readonly IAuthorityIdentityProviderRegistry registry;
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
@@ -30,6 +33,7 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
public ValidateAccessTokenHandler(
IAuthorityTokenStore tokenStore,
IAuthorityMongoSessionAccessor sessionAccessor,
IAuthorityClientStore clientStore,
IAuthorityIdentityProviderRegistry registry,
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
@@ -39,6 +43,7 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
ILogger<ValidateAccessTokenHandler> logger)
{
this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
this.sessionAccessor = sessionAccessor ?? throw new ArgumentNullException(nameof(sessionAccessor));
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor));
@@ -74,10 +79,12 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
? context.TokenId
: context.Principal.GetClaim(OpenIddictConstants.Claims.JwtId);
var session = await sessionAccessor.GetSessionAsync(context.CancellationToken).ConfigureAwait(false);
AuthorityTokenDocument? tokenDocument = null;
if (!string.IsNullOrWhiteSpace(tokenId))
{
tokenDocument = await tokenStore.FindByTokenIdAsync(tokenId, context.CancellationToken).ConfigureAwait(false);
tokenDocument = await tokenStore.FindByTokenIdAsync(tokenId, context.CancellationToken, session).ConfigureAwait(false);
if (tokenDocument is not null)
{
if (!string.Equals(tokenDocument.Status, "valid", StringComparison.OrdinalIgnoreCase))
@@ -101,13 +108,13 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
if (!context.IsRejected && tokenDocument is not null)
{
await TrackTokenUsageAsync(context, tokenDocument, context.Principal).ConfigureAwait(false);
await TrackTokenUsageAsync(context, tokenDocument, context.Principal, session).ConfigureAwait(false);
}
var clientId = context.Principal.GetClaim(OpenIddictConstants.Claims.ClientId);
if (!string.IsNullOrWhiteSpace(clientId))
{
var clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken).ConfigureAwait(false);
var clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken, session).ConfigureAwait(false);
if (clientDocument is null || clientDocument.Disabled)
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "The client associated with the token is not permitted.");
@@ -165,14 +172,15 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
private async ValueTask TrackTokenUsageAsync(
OpenIddictServerEvents.ValidateTokenContext context,
AuthorityTokenDocument tokenDocument,
ClaimsPrincipal principal)
ClaimsPrincipal principal,
IClientSessionHandle session)
{
var metadata = metadataAccessor.GetMetadata();
var remoteAddress = metadata?.RemoteIp;
var userAgent = metadata?.UserAgent;
var observedAt = clock.GetUtcNow();
var result = await tokenStore.RecordUsageAsync(tokenDocument.TokenId, remoteAddress, userAgent, observedAt, context.CancellationToken)
var result = await tokenStore.RecordUsageAsync(tokenDocument.TokenId, remoteAddress, userAgent, observedAt, context.CancellationToken, session)
.ConfigureAwait(false);
switch (result.Status)

View File

@@ -10,6 +10,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.Server.Kestrel.Https;
using Microsoft.Extensions.Logging.Abstractions;
using OpenIddict.Abstractions;
using OpenIddict.Server;
@@ -37,6 +38,9 @@ using StellaOps.Authority.Revocation;
using StellaOps.Authority.Signing;
using StellaOps.Cryptography;
using StellaOps.Authority.Storage.Mongo.Documents;
#if STELLAOPS_AUTH_SECURITY
using StellaOps.Auth.Security.Dpop;
#endif
var builder = WebApplication.CreateBuilder(args);
@@ -66,6 +70,15 @@ var authorityConfiguration = StellaOpsAuthorityConfiguration.Build(options =>
};
});
builder.WebHost.ConfigureKestrel(options =>
{
options.ConfigureHttpsDefaults(https =>
{
https.ClientCertificateMode = ClientCertificateMode.AllowCertificate;
https.CheckCertificateRevocation = true;
});
});
builder.Configuration.AddConfiguration(authorityConfiguration.Configuration);
builder.Host.UseSerilog((context, _, loggerConfiguration) =>
@@ -86,6 +99,28 @@ builder.Services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System);
builder.Services.TryAddSingleton<IAuthorityRateLimiterMetadataAccessor, AuthorityRateLimiterMetadataAccessor>();
builder.Services.TryAddSingleton<IAuthorityRateLimiterPartitionKeyResolver, DefaultAuthorityRateLimiterPartitionKeyResolver>();
#if STELLAOPS_AUTH_SECURITY
var senderConstraints = authorityOptions.Security.SenderConstraints;
builder.Services.AddOptions<DpopValidationOptions>()
.Configure(options =>
{
options.ProofLifetime = senderConstraints.Dpop.ProofLifetime;
options.AllowedClockSkew = senderConstraints.Dpop.AllowedClockSkew;
options.ReplayWindow = senderConstraints.Dpop.ReplayWindow;
options.AllowedAlgorithms.Clear();
foreach (var algorithm in senderConstraints.Dpop.NormalizedAlgorithms)
{
options.AllowedAlgorithms.Add(algorithm);
}
})
.PostConfigure(static options => options.Validate());
builder.Services.TryAddSingleton<IDpopReplayCache>(provider => new InMemoryDpopReplayCache(provider.GetService<TimeProvider>()));
builder.Services.TryAddSingleton<IDpopProofValidator, DpopProofValidator>();
#endif
builder.Services.AddRateLimiter(rateLimiterOptions =>
{
AuthorityRateLimiter.Configure(rateLimiterOptions, authorityOptions);

View File

@@ -5,6 +5,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<DefineConstants>$(DefineConstants);STELLAOPS_AUTH_SECURITY</DefineConstants>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OpenIddict.Abstractions" Version="6.4.0" />
@@ -22,6 +23,7 @@
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" />
<ProjectReference Include="..\..\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />

View File

@@ -19,6 +19,11 @@
| AUTHCORE-BUILD-OPENIDDICT | DONE (2025-10-14) | Authority Core | SEC2.HOST | Adapt host/audit handlers for OpenIddict 6.4 API surface (no `OpenIddictServerTransaction`) and restore Authority solution build. | ✅ Build `dotnet build src/StellaOps.Authority.sln` succeeds; ✅ Audit correlation + tamper logging verified under new abstractions; ✅ Tests updated. |
| AUTHCORE-STORAGE-DEVICE-TOKENS | DONE (2025-10-14) | Authority Core, Storage Guild | AUTHCORE-BUILD-OPENIDDICT | Reintroduce `AuthorityTokenDeviceDocument` + projections removed during refactor so storage layer compiles. | ✅ Document type restored with mappings/migrations; ✅ Storage tests cover device artifacts; ✅ Authority solution build green. |
| AUTHCORE-BOOTSTRAP-INVITES | DONE (2025-10-14) | Authority Core, DevOps | AUTHCORE-STORAGE-DEVICE-TOKENS | Wire bootstrap invite cleanup service against restored document schema and re-enable lifecycle tests. | ✅ `BootstrapInviteCleanupService` passes integration tests; ✅ Operator guide updated if behavior changes; ✅ Build/test matrices green. |
| AUTHSTORAGE-MONGO-08-001 | TODO | Authority Core & Storage Guild | — | Harden Mongo session usage with causal consistency for mutations and follow-up reads. | • Scoped middleware/service creates `IClientSessionHandle` with causal consistency + majority read/write concerns<br>• Stores accept optional session parameter and reuse it for write + immediate reads<br>• GraphQL/HTTP pipelines updated to flow session through post-mutation queries<br>• Replica-set integration test exercises primary election and verifies read-your-write guarantees |
| AUTHSTORAGE-MONGO-08-001 | DONE (2025-10-19) | Authority Core & Storage Guild | — | Harden Mongo session usage with causal consistency for mutations and follow-up reads. | • Scoped middleware/service creates `IClientSessionHandle` with causal consistency + majority read/write concerns<br>• Stores accept optional session parameter and reuse it for write + immediate reads<br>• GraphQL/HTTP pipelines updated to flow session through post-mutation queries<br>• Replica-set integration test exercises primary election and verifies read-your-write guarantees |
| AUTH-DPOP-11-001 | DOING (2025-10-19) | Authority Core & Security Guild | — | Implement DPoP proof validation + nonce handling for high-value audiences per architecture. | • DPoP proof validator verifies method/uri/hash, jwk thumbprint, and replay nonce per spec<br>• Nonce issuance endpoint integrated with audit + rate limits; high-value audiences enforce nonce requirement<br>• Integration tests cover success/failure paths (expired nonce, replay, invalid proof) and docs outline operator configuration |
| AUTH-MTLS-11-002 | DOING (2025-10-19) | Authority Core & Security Guild | — | Add OAuth mTLS client credential support with certificate-bound tokens and introspection updates. | • Client registration stores certificate bindings and enforces SAN/thumbprint validation during token issuance<br>• Token endpoint returns certificate-bound access tokens + PoP proof metadata; introspection reflects binding state<br>• End-to-end tests validate successful mTLS issuance, rejection of unbound certs, and docs capture configuration/rotation guidance |
> Remark (2025-10-19, AUTHSTORAGE-MONGO-08-001): Session accessor wired through Authority pipeline; stores accept optional sessions; added replica-set election regression test for read-your-write.
> Remark (2025-10-19, AUTH-DPOP-11-001): Prerequisites reviewed—none outstanding; status moved to DOING for Wave 0 kickoff. Design blueprint recorded in `docs/dev/authority-dpop-mtls-plan.md`.
> Remark (2025-10-19, AUTH-MTLS-11-002): Prerequisites reviewed—none outstanding; status moved to DOING for Wave 0 kickoff. mTLS flow design captured in `docs/dev/authority-dpop-mtls-plan.md`.
> Update status columns (TODO / DOING / DONE / BLOCKED) together with code changes. Always run `dotnet test src/StellaOps.Authority.sln` when touching host logic.