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:
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user