Add authority bootstrap flows and Concelier ops runbooks
This commit is contained in:
@@ -15,7 +15,8 @@ public class StandardClientProvisioningStoreTests
|
||||
public async Task CreateOrUpdateAsync_HashesSecretAndPersistsDocument()
|
||||
{
|
||||
var store = new TrackingClientStore();
|
||||
var provisioning = new StandardClientProvisioningStore("standard", store);
|
||||
var revocations = new TrackingRevocationStore();
|
||||
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
|
||||
|
||||
var registration = new AuthorityClientRegistration(
|
||||
clientId: "bootstrap-client",
|
||||
@@ -63,4 +64,21 @@ public class StandardClientProvisioningStoreTests
|
||||
return ValueTask.FromResult(removed);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TrackingRevocationStore : IAuthorityRevocationStore
|
||||
{
|
||||
public List<AuthorityRevocationDocument> Upserts { get; } = new();
|
||||
|
||||
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
Upserts.Add(document);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(true);
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Mongo2Go;
|
||||
@@ -58,6 +59,21 @@ public class StandardPluginRegistrarTests
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IMongoDatabase>(database);
|
||||
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
|
||||
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
||||
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
||||
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
||||
|
||||
var registrar = new StandardPluginRegistrar();
|
||||
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
|
||||
@@ -83,6 +99,53 @@ public class StandardPluginRegistrarTests
|
||||
Assert.True(verification.User?.RequiresPasswordReset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_LogsWarning_WhenPasswordPolicyWeaker()
|
||||
{
|
||||
using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
|
||||
var client = new MongoClient(runner.ConnectionString);
|
||||
var database = client.GetDatabase("registrar-password-policy");
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["passwordPolicy:minimumLength"] = "6",
|
||||
["passwordPolicy:requireUppercase"] = "false",
|
||||
["passwordPolicy:requireLowercase"] = "false",
|
||||
["passwordPolicy:requireDigit"] = "false",
|
||||
["passwordPolicy:requireSymbol"] = "false"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var manifest = new AuthorityPluginManifest(
|
||||
"standard",
|
||||
"standard",
|
||||
true,
|
||||
typeof(StandardPluginRegistrar).Assembly.GetName().Name,
|
||||
typeof(StandardPluginRegistrar).Assembly.Location,
|
||||
new[] { AuthorityPluginCapabilities.Password },
|
||||
new Dictionary<string, string?>(),
|
||||
"standard.yaml");
|
||||
|
||||
var pluginContext = new AuthorityPluginContext(manifest, configuration);
|
||||
var services = new ServiceCollection();
|
||||
var loggerProvider = new CapturingLoggerProvider();
|
||||
services.AddLogging(builder => builder.AddProvider(loggerProvider));
|
||||
services.AddSingleton<IMongoDatabase>(database);
|
||||
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
|
||||
|
||||
var registrar = new StandardPluginRegistrar();
|
||||
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
_ = provider.GetRequiredService<StandardUserCredentialStore>();
|
||||
|
||||
Assert.Contains(loggerProvider.Entries, entry =>
|
||||
entry.Level == LogLevel.Warning &&
|
||||
entry.Category.Contains(typeof(StandardPluginRegistrar).FullName!, StringComparison.Ordinal) &&
|
||||
entry.Message.Contains("weaker password policy", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_ForcesPasswordCapability_WhenManifestMissing()
|
||||
{
|
||||
@@ -106,6 +169,8 @@ public class StandardPluginRegistrarTests
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IMongoDatabase>(database);
|
||||
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
|
||||
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
var registrar = new StandardPluginRegistrar();
|
||||
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
|
||||
@@ -209,6 +274,61 @@ public class StandardPluginRegistrarTests
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record CapturedLogEntry(string Category, LogLevel Level, string Message);
|
||||
|
||||
internal sealed class CapturingLoggerProvider : ILoggerProvider
|
||||
{
|
||||
public List<CapturedLogEntry> Entries { get; } = new();
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => new CapturingLogger(categoryName, Entries);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class CapturingLogger : ILogger
|
||||
{
|
||||
private readonly string category;
|
||||
private readonly List<CapturedLogEntry> entries;
|
||||
|
||||
public CapturingLogger(string category, List<CapturedLogEntry> entries)
|
||||
{
|
||||
this.category = category;
|
||||
this.entries = entries;
|
||||
}
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
entries.Add(new CapturedLogEntry(category, logLevel, formatter(state, exception)));
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class StubRevocationStore : IAuthorityRevocationStore
|
||||
{
|
||||
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(false);
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
|
||||
}
|
||||
|
||||
internal sealed class InMemoryClientStore : IAuthorityClientStore
|
||||
{
|
||||
private readonly Dictionary<string, AuthorityClientDocument> clients = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -71,6 +71,41 @@ internal sealed class PasswordPolicyOptions
|
||||
throw new InvalidOperationException($"Standard plugin '{pluginName}' requires passwordPolicy.minimumLength to be greater than zero.");
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsWeakerThan(PasswordPolicyOptions other)
|
||||
{
|
||||
if (other is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (MinimumLength < other.MinimumLength)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!RequireUppercase && other.RequireUppercase)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!RequireLowercase && other.RequireLowercase)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!RequireDigit && other.RequireDigit)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!RequireSymbol && other.RequireSymbol)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LockoutOptions
|
||||
|
||||
@@ -51,6 +51,25 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
|
||||
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
|
||||
var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider);
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>();
|
||||
|
||||
var baselinePolicy = new PasswordPolicyOptions();
|
||||
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy))
|
||||
{
|
||||
registrarLogger.LogWarning(
|
||||
"Standard plugin '{Plugin}' configured a weaker password policy (minLength={Length}, requireUpper={Upper}, requireLower={Lower}, requireDigit={Digit}, requireSymbol={Symbol}) than the baseline (minLength={BaseLength}, requireUpper={BaseUpper}, requireLower={BaseLower}, requireDigit={BaseDigit}, requireSymbol={BaseSymbol}).",
|
||||
pluginName,
|
||||
pluginOptions.PasswordPolicy.MinimumLength,
|
||||
pluginOptions.PasswordPolicy.RequireUppercase,
|
||||
pluginOptions.PasswordPolicy.RequireLowercase,
|
||||
pluginOptions.PasswordPolicy.RequireDigit,
|
||||
pluginOptions.PasswordPolicy.RequireSymbol,
|
||||
baselinePolicy.MinimumLength,
|
||||
baselinePolicy.RequireUppercase,
|
||||
baselinePolicy.RequireLowercase,
|
||||
baselinePolicy.RequireDigit,
|
||||
baselinePolicy.RequireSymbol);
|
||||
}
|
||||
|
||||
return new StandardUserCredentialStore(
|
||||
pluginName,
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
| PLG6.DOC | DONE (2025-10-11) | BE-Auth Plugin, Docs Guild | PLG1–PLG5 | Final polish + diagrams for plugin developer guide (AUTHPLUG-DOCS-01-001). | Docs team delivers copy-edit + exported diagrams; PR merged. |
|
||||
| SEC1.PLG | DONE (2025-10-11) | Security Guild, BE-Auth Plugin | SEC1.A (StellaOps.Cryptography) | Swap Standard plugin hashing to Argon2id via `StellaOps.Cryptography` abstractions; keep PBKDF2 verification for legacy. | ✅ `StandardUserCredentialStore` uses `ICryptoProvider` to hash/check; ✅ Transparent rehash on success; ✅ Unit tests cover tamper + legacy rehash. |
|
||||
| SEC1.OPT | DONE (2025-10-11) | Security Guild | SEC1.PLG | Expose password hashing knobs in `StandardPluginOptions` (`memoryKiB`, `iterations`, `parallelism`, `algorithm`) with validation. | ✅ Options bound from YAML; ✅ Invalid configs throw; ✅ Docs include tuning guidance. |
|
||||
| SEC2.PLG | TODO | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`. | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. |
|
||||
| SEC3.PLG | TODO | Security Guild, BE-Auth Plugin | CORE8, SEC3.A (rate limiter) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after). | ✅ Audit record includes retry-after; ✅ Tests confirm lockout + limiter interplay. |
|
||||
| SEC2.PLG | DOING (2025-10-14) | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`. | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. |
|
||||
| SEC3.PLG | DOING (2025-10-14) | Security Guild, BE-Auth Plugin | CORE8, SEC3.A (rate limiter) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after). | ✅ Audit record includes retry-after; ✅ Tests confirm lockout + limiter interplay. |
|
||||
| SEC4.PLG | DONE (2025-10-12) | Security Guild | SEC4.A (revocation schema) | Provide plugin hooks so revoked users/clients write reasons for revocation bundle export. | ✅ Revocation exporter consumes plugin data; ✅ Tests cover revoked user/client output. |
|
||||
| SEC5.PLG | TODO | Security Guild | SEC5.A (threat model) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog. | ✅ Threat model lists plugin attack surfaces; ✅ Mitigation items filed. |
|
||||
| SEC5.PLG | DOING (2025-10-14) | Security Guild | SEC5.A (threat model) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog. | ✅ Threat model lists plugin attack surfaces; ✅ Mitigation items filed. |
|
||||
| PLG4-6.CAPABILITIES | BLOCKED (2025-10-12) | BE-Auth Plugin, Docs Guild | PLG1–PLG3 | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | ✅ Capability metadata + validation merged; ✅ Plugin guide updated with final copy & diagrams; ✅ Release notes mention new toggles. <br>⛔ Blocked awaiting Authority rate-limiter stream (CORE8/SEC3) to resume so doc updates reflect final limiter behaviour. |
|
||||
| PLG7.RFC | REVIEW | BE-Auth Plugin, Security Guild | PLG4 | Socialize LDAP plugin RFC (`docs/rfcs/authority-plugin-ldap.md`) and capture guild feedback. | ✅ Guild review sign-off recorded; ✅ Follow-up issues filed in module boards. |
|
||||
| PLG6.DIAGRAM | TODO | Docs Guild | PLG6.DOC | Export final sequence/component diagrams for the developer guide and add offline-friendly assets under `docs/assets/authority`. | ✅ Mermaid sources committed; ✅ Rendered SVG/PNG linked from Section 2 + Section 9; ✅ Docs build preview shared with Plugin + Docs guilds. |
|
||||
|
||||
> Update statuses to DOING/DONE/BLOCKED as you make progress. Always run `dotnet test` for touched projects before marking DONE.
|
||||
|
||||
> Remark (2025-10-13, PLG6.DOC/PLG6.DIAGRAM): Security Guild delivered `docs/security/rate-limits.md`; Docs team can lift Section 3 (tuning table + alerts) into the developer guide diagrams when rendering assets.
|
||||
|
||||
@@ -22,5 +22,6 @@ public static class AuthorityMongoDefaults
|
||||
public const string LoginAttempts = "authority_login_attempts";
|
||||
public const string Revocations = "authority_revocations";
|
||||
public const string RevocationState = "authority_revocation_state";
|
||||
public const string Invites = "authority_bootstrap_invites";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a bootstrap invitation token for provisioning users or clients.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AuthorityBootstrapInviteDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
|
||||
|
||||
[BsonElement("token")]
|
||||
public string Token { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
[BsonElement("type")]
|
||||
public string Type { get; set; } = "user";
|
||||
|
||||
[BsonElement("provider")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Provider { get; set; }
|
||||
|
||||
[BsonElement("target")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Target { get; set; }
|
||||
|
||||
[BsonElement("issuedAt")]
|
||||
public DateTimeOffset IssuedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[BsonElement("issuedBy")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? IssuedBy { get; set; }
|
||||
|
||||
[BsonElement("expiresAt")]
|
||||
public DateTimeOffset ExpiresAt { get; set; } = DateTimeOffset.UtcNow.AddDays(2);
|
||||
|
||||
[BsonElement("status")]
|
||||
public string Status { get; set; } = AuthorityBootstrapInviteStatuses.Pending;
|
||||
|
||||
[BsonElement("reservedAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? ReservedAt { get; set; }
|
||||
|
||||
[BsonElement("reservedBy")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ReservedBy { get; set; }
|
||||
|
||||
[BsonElement("consumedAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? ConsumedAt { get; set; }
|
||||
|
||||
[BsonElement("consumedBy")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ConsumedBy { get; set; }
|
||||
|
||||
[BsonElement("metadata")]
|
||||
[BsonIgnoreIfNull]
|
||||
public Dictionary<string, string?>? Metadata { get; set; }
|
||||
}
|
||||
|
||||
public static class AuthorityBootstrapInviteStatuses
|
||||
{
|
||||
public const string Pending = "pending";
|
||||
public const string Reserved = "reserved";
|
||||
public const string Consumed = "consumed";
|
||||
public const string Expired = "expired";
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
@@ -61,6 +62,11 @@ public sealed class AuthorityTokenDocument
|
||||
[BsonIgnoreIfNull]
|
||||
public string? RevokedReasonDescription { get; set; }
|
||||
|
||||
|
||||
[BsonElement("devices")]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<BsonDocument>? Devices { get; set; }
|
||||
|
||||
[BsonElement("revokedMetadata")]
|
||||
[BsonIgnoreIfNull]
|
||||
public Dictionary<string, string?>? RevokedMetadata { get; set; }
|
||||
|
||||
@@ -98,12 +98,19 @@ public static class ServiceCollectionExtensions
|
||||
return database.GetCollection<AuthorityRevocationExportStateDocument>(AuthorityMongoDefaults.Collections.RevocationState);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityBootstrapInviteDocument>(AuthorityMongoDefaults.Collections.Invites);
|
||||
});
|
||||
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityUserCollectionInitializer>();
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityClientCollectionInitializer>();
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityScopeCollectionInitializer>();
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityTokenCollectionInitializer>();
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityLoginAttemptCollectionInitializer>();
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityRevocationCollectionInitializer>();
|
||||
services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityBootstrapInviteCollectionInitializer>();
|
||||
|
||||
services.TryAddSingleton<IAuthorityUserStore, AuthorityUserStore>();
|
||||
services.TryAddSingleton<IAuthorityClientStore, AuthorityClientStore>();
|
||||
@@ -112,6 +119,7 @@ public static class ServiceCollectionExtensions
|
||||
services.TryAddSingleton<IAuthorityLoginAttemptStore, AuthorityLoginAttemptStore>();
|
||||
services.TryAddSingleton<IAuthorityRevocationStore, AuthorityRevocationStore>();
|
||||
services.TryAddSingleton<IAuthorityRevocationExportStateStore, AuthorityRevocationExportStateStore>();
|
||||
services.TryAddSingleton<IAuthorityBootstrapInviteStore, AuthorityBootstrapInviteStore>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
|
||||
internal sealed class AuthorityBootstrapInviteCollectionInitializer : IAuthorityCollectionInitializer
|
||||
{
|
||||
private static readonly CreateIndexModel<AuthorityBootstrapInviteDocument>[] Indexes =
|
||||
{
|
||||
new CreateIndexModel<AuthorityBootstrapInviteDocument>(
|
||||
Builders<AuthorityBootstrapInviteDocument>.IndexKeys.Ascending(i => i.Token),
|
||||
new CreateIndexOptions { Unique = true, Name = "idx_invite_token" }),
|
||||
new CreateIndexModel<AuthorityBootstrapInviteDocument>(
|
||||
Builders<AuthorityBootstrapInviteDocument>.IndexKeys.Ascending(i => i.Status).Ascending(i => i.ExpiresAt),
|
||||
new CreateIndexOptions { Name = "idx_invite_status_expires" })
|
||||
};
|
||||
|
||||
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
|
||||
var collection = database.GetCollection<AuthorityBootstrapInviteDocument>(AuthorityMongoDefaults.Collections.Invites);
|
||||
await collection.Indexes.CreateManyAsync(Indexes, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal sealed class AuthorityBootstrapInviteStore : IAuthorityBootstrapInviteStore
|
||||
{
|
||||
private readonly IMongoCollection<AuthorityBootstrapInviteDocument> collection;
|
||||
|
||||
public AuthorityBootstrapInviteStore(IMongoCollection<AuthorityBootstrapInviteDocument> collection)
|
||||
=> this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
|
||||
public async ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return document;
|
||||
}
|
||||
|
||||
public async ValueTask<BootstrapInviteReservationResult> TryReserveAsync(
|
||||
string token,
|
||||
string expectedType,
|
||||
DateTimeOffset now,
|
||||
string? reservedBy,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, null);
|
||||
}
|
||||
|
||||
var normalizedToken = token.Trim();
|
||||
var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.And(
|
||||
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, normalizedToken),
|
||||
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Status, AuthorityBootstrapInviteStatuses.Pending));
|
||||
|
||||
var update = Builders<AuthorityBootstrapInviteDocument>.Update
|
||||
.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Reserved)
|
||||
.Set(i => i.ReservedAt, now)
|
||||
.Set(i => i.ReservedBy, reservedBy);
|
||||
|
||||
var options = new FindOneAndUpdateOptions<AuthorityBootstrapInviteDocument>
|
||||
{
|
||||
ReturnDocument = ReturnDocument.After
|
||||
};
|
||||
|
||||
var 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);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, null);
|
||||
}
|
||||
|
||||
if (existing.Status is AuthorityBootstrapInviteStatuses.Consumed or AuthorityBootstrapInviteStatuses.Reserved)
|
||||
{
|
||||
return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.AlreadyUsed, existing);
|
||||
}
|
||||
|
||||
if (existing.Status == AuthorityBootstrapInviteStatuses.Expired || existing.ExpiresAt <= now)
|
||||
{
|
||||
return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.Expired, existing);
|
||||
}
|
||||
|
||||
return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, existing);
|
||||
}
|
||||
|
||||
if (!string.Equals(invite.Type, expectedType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await ReleaseAsync(normalizedToken, cancellationToken).ConfigureAwait(false);
|
||||
return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, invite);
|
||||
}
|
||||
|
||||
if (invite.ExpiresAt <= now)
|
||||
{
|
||||
await MarkExpiredAsync(normalizedToken, cancellationToken).ConfigureAwait(false);
|
||||
return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.Expired, invite);
|
||||
}
|
||||
|
||||
return new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.Reserved, invite);
|
||||
}
|
||||
|
||||
public async ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken)
|
||||
{
|
||||
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);
|
||||
|
||||
return result.ModifiedCount > 0;
|
||||
}
|
||||
|
||||
public async ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken)
|
||||
{
|
||||
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);
|
||||
|
||||
return result.ModifiedCount > 0;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<AuthorityBootstrapInviteDocument>.Filter.And(
|
||||
Builders<AuthorityBootstrapInviteDocument>.Filter.Lte(i => i.ExpiresAt, now),
|
||||
Builders<AuthorityBootstrapInviteDocument>.Filter.In(
|
||||
i => i.Status,
|
||||
new[] { AuthorityBootstrapInviteStatuses.Pending, AuthorityBootstrapInviteStatuses.Reserved }));
|
||||
|
||||
var update = Builders<AuthorityBootstrapInviteDocument>.Update
|
||||
.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Expired)
|
||||
.Set(i => i.ReservedAt, null)
|
||||
.Set(i => i.ReservedBy, null);
|
||||
|
||||
var 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);
|
||||
|
||||
return expired;
|
||||
}
|
||||
|
||||
private async Task MarkExpiredAsync(string token, CancellationToken cancellationToken)
|
||||
{
|
||||
await collection.UpdateOneAsync(
|
||||
Builders<AuthorityBootstrapInviteDocument>.Filter.Eq(i => i.Token, token),
|
||||
Builders<AuthorityBootstrapInviteDocument>.Update.Set(i => i.Status, AuthorityBootstrapInviteStatuses.Expired),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
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;
|
||||
@@ -86,6 +90,86 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
|
||||
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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tokenId))
|
||||
{
|
||||
return new TokenUsageUpdateResult(TokenUsageUpdateStatus.NotFound, null, null);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(remoteAddress) && string.IsNullOrWhiteSpace(userAgent))
|
||||
{
|
||||
return new TokenUsageUpdateResult(TokenUsageUpdateStatus.MissingMetadata, remoteAddress, userAgent);
|
||||
}
|
||||
|
||||
var id = tokenId.Trim();
|
||||
var token = await collection
|
||||
.Find(t => t.TokenId == id)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (token is null)
|
||||
{
|
||||
return new TokenUsageUpdateResult(TokenUsageUpdateStatus.NotFound, remoteAddress, userAgent);
|
||||
}
|
||||
|
||||
token.Devices ??= new List<BsonDocument>();
|
||||
|
||||
string? normalizedAddress = string.IsNullOrWhiteSpace(remoteAddress) ? null : remoteAddress.Trim();
|
||||
string? normalizedAgent = string.IsNullOrWhiteSpace(userAgent) ? null : userAgent.Trim();
|
||||
|
||||
var device = token.Devices.FirstOrDefault(d =>
|
||||
string.Equals(GetString(d, "remoteAddress"), normalizedAddress, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(GetString(d, "userAgent"), normalizedAgent, StringComparison.Ordinal));
|
||||
var suspicious = false;
|
||||
|
||||
if (device is null)
|
||||
{
|
||||
suspicious = token.Devices.Count > 0;
|
||||
var document = new BsonDocument
|
||||
{
|
||||
{ "remoteAddress", normalizedAddress },
|
||||
{ "userAgent", normalizedAgent },
|
||||
{ "firstSeen", BsonDateTime.Create(observedAt.UtcDateTime) },
|
||||
{ "lastSeen", BsonDateTime.Create(observedAt.UtcDateTime) },
|
||||
{ "useCount", 1 }
|
||||
};
|
||||
|
||||
token.Devices.Add(document);
|
||||
}
|
||||
else
|
||||
{
|
||||
device["lastSeen"] = BsonDateTime.Create(observedAt.UtcDateTime);
|
||||
device["useCount"] = device.TryGetValue("useCount", out var existingCount) && existingCount.IsInt32
|
||||
? existingCount.AsInt32 + 1
|
||||
: 1;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
return new TokenUsageUpdateResult(suspicious ? TokenUsageUpdateStatus.SuspectedReplay : TokenUsageUpdateStatus.Recorded, normalizedAddress, normalizedAgent);
|
||||
}
|
||||
|
||||
private static string? GetString(BsonDocument document, string name)
|
||||
{
|
||||
if (!document.TryGetValue(name, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value switch
|
||||
{
|
||||
{ IsString: true } => value.AsString,
|
||||
{ IsBsonNull: true } => null,
|
||||
_ => value.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
public async ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<AuthorityTokenDocument>.Filter.And(
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityBootstrapInviteStore
|
||||
{
|
||||
ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<BootstrapInviteReservationResult> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public enum BootstrapInviteReservationStatus
|
||||
{
|
||||
Reserved,
|
||||
NotFound,
|
||||
Expired,
|
||||
AlreadyUsed
|
||||
}
|
||||
|
||||
public sealed record BootstrapInviteReservationResult(BootstrapInviteReservationStatus Status, AuthorityBootstrapInviteDocument? Invite);
|
||||
@@ -1,3 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
@@ -21,5 +23,17 @@ public interface IAuthorityTokenStore
|
||||
|
||||
ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public enum TokenUsageUpdateStatus
|
||||
{
|
||||
Recorded,
|
||||
SuspectedReplay,
|
||||
MissingMetadata,
|
||||
NotFound
|
||||
}
|
||||
|
||||
public sealed record TokenUsageUpdateResult(TokenUsageUpdateStatus Status, string? RemoteAddress, string? UserAgent);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Authority.Bootstrap;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Bootstrap;
|
||||
|
||||
public sealed class BootstrapInviteCleanupServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SweepExpiredInvitesAsync_ExpiresInvitesAndEmitsAuditRecords()
|
||||
{
|
||||
var now = new DateTimeOffset(2025, 10, 14, 12, 0, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FakeTimeProvider(now);
|
||||
|
||||
var invites = new List<AuthorityBootstrapInviteDocument>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Token = "token-1",
|
||||
Type = BootstrapInviteTypes.User,
|
||||
ExpiresAt = now.AddMinutes(-5),
|
||||
Provider = "standard",
|
||||
Target = "alice@example.com",
|
||||
Status = AuthorityBootstrapInviteStatuses.Pending
|
||||
},
|
||||
new()
|
||||
{
|
||||
Token = "token-2",
|
||||
Type = BootstrapInviteTypes.Client,
|
||||
ExpiresAt = now.AddMinutes(-1),
|
||||
Provider = "standard",
|
||||
Target = "client-1",
|
||||
Status = AuthorityBootstrapInviteStatuses.Reserved
|
||||
}
|
||||
};
|
||||
|
||||
var store = new FakeInviteStore(invites);
|
||||
var sink = new CapturingAuthEventSink();
|
||||
var service = new BootstrapInviteCleanupService(store, sink, timeProvider, NullLogger<BootstrapInviteCleanupService>.Instance);
|
||||
|
||||
await service.SweepExpiredInvitesAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(store.ExpireCalled);
|
||||
Assert.Equal(2, sink.Events.Count);
|
||||
Assert.All(sink.Events, record => Assert.Equal("authority.bootstrap.invite.expired", record.EventType));
|
||||
Assert.Contains(sink.Events, record => record.Properties.Any(property => property.Name == "invite.token" && property.Value.Value == "token-1"));
|
||||
Assert.Contains(sink.Events, record => record.Properties.Any(property => property.Name == "invite.token" && property.Value.Value == "token-2"));
|
||||
}
|
||||
|
||||
private sealed class FakeInviteStore : IAuthorityBootstrapInviteStore
|
||||
{
|
||||
private readonly IReadOnlyList<AuthorityBootstrapInviteDocument> invites;
|
||||
|
||||
public FakeInviteStore(IReadOnlyList<AuthorityBootstrapInviteDocument> invites)
|
||||
=> this.invites = invites;
|
||||
|
||||
public bool ExpireCalled { get; private set; }
|
||||
|
||||
public ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public ValueTask<BootstrapInviteReservationResult> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, null));
|
||||
|
||||
public ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(false);
|
||||
|
||||
public ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(false);
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
ExpireCalled = true;
|
||||
return ValueTask.FromResult(invites);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingAuthEventSink : IAuthEventSink
|
||||
{
|
||||
public List<AuthEventRecord> Events { get; } = new();
|
||||
|
||||
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
Events.Add(record);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
using MongoDB.Bson;
|
||||
using static StellaOps.Authority.Tests.OpenIddict.TestHelpers;
|
||||
|
||||
namespace StellaOps.Authority.Tests.OpenIddict;
|
||||
@@ -76,7 +77,7 @@ public class ClientCredentialsHandlersTests
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.False(context.IsRejected);
|
||||
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
|
||||
Assert.Same(clientDocument, context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty]);
|
||||
|
||||
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
|
||||
@@ -84,6 +85,36 @@ public class ClientCredentialsHandlersTests
|
||||
Assert.Equal(clientDocument.Plugin, context.Transaction.Properties[AuthorityOpenIddictConstants.ClientProviderTransactionProperty]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_EmitsTamperAuditEvent_WhenUnexpectedParametersPresent()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "jobs:read");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var sink = new TestAuthEventSink();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
sink,
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
|
||||
transaction.Request?.SetParameter("unexpected_param", "value");
|
||||
|
||||
await handler.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction));
|
||||
|
||||
var tamperEvent = Assert.Single(sink.Events, record => record.EventType == "authority.token.tamper");
|
||||
Assert.Contains(tamperEvent.Properties, property =>
|
||||
string.Equals(property.Name, "request.unexpected_parameter", StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(property.Value.Value, "unexpected_param", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleClientCredentials_PersistsTokenAndEnrichesClaims()
|
||||
{
|
||||
@@ -98,22 +129,30 @@ public class ClientCredentialsHandlersTests
|
||||
var tokenStore = new TestTokenStore();
|
||||
var authSink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var validateHandler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
authSink,
|
||||
metadataAccessor,
|
||||
TimeProvider.System,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, secret: null, scope: "jobs:trigger");
|
||||
transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(30);
|
||||
|
||||
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
await validateHandler.HandleAsync(validateContext);
|
||||
Assert.False(validateContext.IsRejected);
|
||||
|
||||
var handler = new HandleClientCredentialsHandler(
|
||||
registry,
|
||||
tokenStore,
|
||||
TimeProvider.System,
|
||||
TestActivitySource,
|
||||
authSink,
|
||||
metadataAccessor,
|
||||
NullLogger<HandleClientCredentialsHandler>.Instance);
|
||||
var persistHandler = new PersistTokensHandler(tokenStore, TimeProvider.System, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, secret: null, scope: "jobs:trigger");
|
||||
transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(30);
|
||||
transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty] = clientDocument;
|
||||
transaction.Properties[AuthorityOpenIddictConstants.ClientProviderTransactionProperty] = clientDocument.Plugin!;
|
||||
transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty] = new[] { "jobs:trigger" };
|
||||
|
||||
var context = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
@@ -161,10 +200,14 @@ public class TokenValidationHandlersTests
|
||||
ClientId = "feedser"
|
||||
};
|
||||
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var auditSink = new TestAuthEventSink();
|
||||
var handler = new ValidateAccessTokenHandler(
|
||||
tokenStore,
|
||||
new TestClientStore(CreateClient()),
|
||||
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(CreateClient())),
|
||||
metadataAccessor,
|
||||
auditSink,
|
||||
TimeProvider.System,
|
||||
TestActivitySource,
|
||||
NullLogger<ValidateAccessTokenHandler>.Instance);
|
||||
@@ -203,10 +246,14 @@ public class TokenValidationHandlersTests
|
||||
|
||||
var registry = new AuthorityIdentityProviderRegistry(new[] { plugin }, NullLogger<AuthorityIdentityProviderRegistry>.Instance);
|
||||
|
||||
var metadataAccessorSuccess = new TestRateLimiterMetadataAccessor();
|
||||
var auditSinkSuccess = new TestAuthEventSink();
|
||||
var handler = new ValidateAccessTokenHandler(
|
||||
new TestTokenStore(),
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
metadataAccessorSuccess,
|
||||
auditSinkSuccess,
|
||||
TimeProvider.System,
|
||||
TestActivitySource,
|
||||
NullLogger<ValidateAccessTokenHandler>.Instance);
|
||||
@@ -229,6 +276,76 @@ public class TokenValidationHandlersTests
|
||||
Assert.False(context.IsRejected);
|
||||
Assert.Contains(principal.Claims, claim => claim.Type == "enriched" && claim.Value == "true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAccessTokenHandler_EmitsReplayAudit_WhenStoreDetectsSuspectedReplay()
|
||||
{
|
||||
var tokenStore = new TestTokenStore();
|
||||
tokenStore.Inserted = new AuthorityTokenDocument
|
||||
{
|
||||
TokenId = "token-replay",
|
||||
Status = "valid",
|
||||
ClientId = "agent",
|
||||
Devices = new List<BsonDocument>
|
||||
{
|
||||
new BsonDocument
|
||||
{
|
||||
{ "remoteAddress", "10.0.0.1" },
|
||||
{ "userAgent", "agent/1.0" },
|
||||
{ "firstSeen", BsonDateTime.Create(DateTimeOffset.UtcNow.AddMinutes(-15)) },
|
||||
{ "lastSeen", BsonDateTime.Create(DateTimeOffset.UtcNow.AddMinutes(-5)) },
|
||||
{ "useCount", 2 }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
tokenStore.UsageCallback = (remote, agent) => new TokenUsageUpdateResult(TokenUsageUpdateStatus.SuspectedReplay, remote, agent);
|
||||
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var metadata = metadataAccessor.GetMetadata();
|
||||
if (metadata is not null)
|
||||
{
|
||||
metadata.RemoteIp = "203.0.113.7";
|
||||
metadata.UserAgent = "agent/2.0";
|
||||
}
|
||||
|
||||
var clientDocument = CreateClient();
|
||||
clientDocument.ClientId = "agent";
|
||||
var auditSink = new TestAuthEventSink();
|
||||
var registry = CreateRegistry(withClientProvisioning: false, clientDescriptor: null);
|
||||
var handler = new ValidateAccessTokenHandler(
|
||||
tokenStore,
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
metadataAccessor,
|
||||
auditSink,
|
||||
TimeProvider.System,
|
||||
TestActivitySource,
|
||||
NullLogger<ValidateAccessTokenHandler>.Instance);
|
||||
|
||||
var transaction = new OpenIddictServerTransaction
|
||||
{
|
||||
Options = new OpenIddictServerOptions(),
|
||||
EndpointType = OpenIddictServerEndpointType.Introspection,
|
||||
Request = new OpenIddictRequest()
|
||||
};
|
||||
|
||||
var principal = CreatePrincipal("agent", "token-replay", "standard");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
|
||||
{
|
||||
Principal = principal,
|
||||
TokenId = "token-replay"
|
||||
};
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.False(context.IsRejected);
|
||||
var replayEvent = Assert.Single(auditSink.Events, record => record.EventType == "authority.token.replay.suspected");
|
||||
Assert.Equal(AuthEventOutcome.Error, replayEvent.Outcome);
|
||||
Assert.NotNull(replayEvent.Network);
|
||||
Assert.Equal("203.0.113.7", replayEvent.Network?.RemoteAddress.Value);
|
||||
Assert.Contains(replayEvent.Properties, property => property.Name == "token.devices.total");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestClientStore : IAuthorityClientStore
|
||||
@@ -263,6 +380,8 @@ internal sealed class TestTokenStore : IAuthorityTokenStore
|
||||
{
|
||||
public AuthorityTokenDocument? Inserted { get; set; }
|
||||
|
||||
public Func<string?, string?, TokenUsageUpdateResult>? UsageCallback { get; set; }
|
||||
|
||||
public ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
Inserted = document;
|
||||
@@ -281,6 +400,9 @@ internal sealed class TestTokenStore : IAuthorityTokenStore
|
||||
public ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(0L);
|
||||
|
||||
public ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(UsageCallback?.Invoke(remoteAddress, userAgent) ?? new TokenUsageUpdateResult(TokenUsageUpdateStatus.Recorded, remoteAddress, userAgent));
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<IReadOnlyList<AuthorityTokenDocument>>(Array.Empty<AuthorityTokenDocument>());
|
||||
}
|
||||
|
||||
@@ -74,6 +74,26 @@ public class PasswordGrantHandlersTests
|
||||
Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.LockedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidatePasswordGrant_EmitsTamperAuditEvent_WhenUnexpectedParametersPresent()
|
||||
{
|
||||
var sink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var registry = CreateRegistry(new SuccessCredentialStore());
|
||||
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
|
||||
|
||||
var transaction = CreatePasswordTransaction("alice", "Password1!");
|
||||
transaction.Request?.SetParameter("unexpected_param", "value");
|
||||
|
||||
await validate.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction));
|
||||
|
||||
var tamperEvent = Assert.Single(sink.Events, record => record.EventType == "authority.token.tamper");
|
||||
Assert.Equal(AuthEventOutcome.Failure, tamperEvent.Outcome);
|
||||
Assert.Contains(tamperEvent.Properties, property =>
|
||||
string.Equals(property.Name, "request.unexpected_parameter", StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(property.Value.Value, "unexpected_param", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static AuthorityIdentityProviderRegistry CreateRegistry(IUserCredentialStore store)
|
||||
{
|
||||
var plugin = new StubIdentityProviderPlugin("stub", store);
|
||||
@@ -104,14 +124,14 @@ public class PasswordGrantHandlersTests
|
||||
Name = name;
|
||||
Type = "stub";
|
||||
var manifest = new AuthorityPluginManifest(
|
||||
name,
|
||||
"stub",
|
||||
enabled: true,
|
||||
version: null,
|
||||
description: null,
|
||||
capabilities: new[] { AuthorityPluginCapabilities.Password },
|
||||
configuration: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase),
|
||||
configPath: $"{name}.yaml");
|
||||
Name: name,
|
||||
Type: "stub",
|
||||
Enabled: true,
|
||||
AssemblyName: null,
|
||||
AssemblyPath: null,
|
||||
Capabilities: new[] { AuthorityPluginCapabilities.Password },
|
||||
Metadata: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase),
|
||||
ConfigPath: $"{name}.yaml");
|
||||
Context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build());
|
||||
Credentials = store;
|
||||
ClaimsEnricher = new NoopClaimsEnricher();
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Bson;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
using StellaOps.Authority;
|
||||
@@ -56,10 +57,10 @@ public sealed class TokenPersistenceIntegrationTests
|
||||
withClientProvisioning: true,
|
||||
clientDescriptor: TestHelpers.CreateDescriptor(clientDocument));
|
||||
|
||||
var validateHandler = new ValidateClientCredentialsHandler(clientStore, registry, TestActivitySource, NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
var authSink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var handleHandler = new HandleClientCredentialsHandler(registry, TestActivitySource, authSink, metadataAccessor, clock, NullLogger<HandleClientCredentialsHandler>.Instance);
|
||||
var validateHandler = new ValidateClientCredentialsHandler(clientStore, registry, TestActivitySource, authSink, metadataAccessor, clock, NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
var handleHandler = new HandleClientCredentialsHandler(registry, tokenStore, clock, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance);
|
||||
var persistHandler = new PersistTokensHandler(tokenStore, clock, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);
|
||||
|
||||
var transaction = TestHelpers.CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:trigger");
|
||||
@@ -148,10 +149,14 @@ public sealed class TokenPersistenceIntegrationTests
|
||||
var revokedAt = now.AddMinutes(1);
|
||||
await tokenStore.UpdateStatusAsync(revokedTokenId, "revoked", revokedAt, "manual", null, null, CancellationToken.None);
|
||||
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var auditSink = new TestAuthEventSink();
|
||||
var handler = new ValidateAccessTokenHandler(
|
||||
tokenStore,
|
||||
clientStore,
|
||||
registry,
|
||||
metadataAccessor,
|
||||
auditSink,
|
||||
clock,
|
||||
TestActivitySource,
|
||||
NullLogger<ValidateAccessTokenHandler>.Instance);
|
||||
@@ -190,6 +195,60 @@ public sealed class TokenPersistenceIntegrationTests
|
||||
Assert.Equal("manual", stored.RevokedReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordUsageAsync_FlagsSuspectedReplay_OnNewDeviceFingerprint()
|
||||
{
|
||||
await ResetCollectionsAsync();
|
||||
|
||||
var issuedAt = new DateTimeOffset(2025, 10, 14, 8, 0, 0, TimeSpan.Zero);
|
||||
var clock = new FakeTimeProvider(issuedAt);
|
||||
|
||||
await using var provider = await BuildMongoProviderAsync(clock);
|
||||
|
||||
var tokenStore = provider.GetRequiredService<IAuthorityTokenStore>();
|
||||
|
||||
var tokenDocument = new AuthorityTokenDocument
|
||||
{
|
||||
TokenId = "token-replay",
|
||||
Type = OpenIddictConstants.TokenTypeHints.AccessToken,
|
||||
ClientId = "client-1",
|
||||
Status = "valid",
|
||||
CreatedAt = issuedAt,
|
||||
Devices = new List<BsonDocument>
|
||||
{
|
||||
new BsonDocument
|
||||
{
|
||||
{ "remoteAddress", "10.0.0.1" },
|
||||
{ "userAgent", "agent/1.0" },
|
||||
{ "firstSeen", BsonDateTime.Create(issuedAt.AddMinutes(-10).UtcDateTime) },
|
||||
{ "lastSeen", BsonDateTime.Create(issuedAt.AddMinutes(-5).UtcDateTime) },
|
||||
{ "useCount", 2 }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await tokenStore.InsertAsync(tokenDocument, CancellationToken.None);
|
||||
|
||||
var result = await tokenStore.RecordUsageAsync(
|
||||
"token-replay",
|
||||
remoteAddress: "10.0.0.2",
|
||||
userAgent: "agent/2.0",
|
||||
observedAt: clock.GetUtcNow(),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(TokenUsageUpdateStatus.SuspectedReplay, result.Status);
|
||||
|
||||
var stored = await tokenStore.FindByTokenIdAsync("token-replay", CancellationToken.None);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(2, stored!.Devices?.Count);
|
||||
Assert.Contains(stored.Devices!, doc =>
|
||||
{
|
||||
var remote = doc.TryGetValue("remoteAddress", out var ra) && ra.IsString ? ra.AsString : null;
|
||||
var agentValue = doc.TryGetValue("userAgent", out var ua) && ua.IsString ? ua.AsString : null;
|
||||
return remote == "10.0.0.2" && agentValue == "agent/2.0";
|
||||
});
|
||||
}
|
||||
|
||||
private async Task ResetCollectionsAsync()
|
||||
{
|
||||
var tokens = fixture.Database.GetCollection<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens);
|
||||
@@ -220,27 +279,3 @@ public sealed class TokenPersistenceIntegrationTests
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestAuthEventSink : IAuthEventSink
|
||||
{
|
||||
public List<AuthEventRecord> Records { get; } = new();
|
||||
|
||||
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
Records.Add(record);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestRateLimiterMetadataAccessor : IAuthorityRateLimiterMetadataAccessor
|
||||
{
|
||||
private readonly AuthorityRateLimiterMetadata metadata = new();
|
||||
|
||||
public AuthorityRateLimiterMetadata? GetMetadata() => metadata;
|
||||
|
||||
public void SetClientId(string? clientId) => metadata.ClientId = string.IsNullOrWhiteSpace(clientId) ? null : clientId;
|
||||
|
||||
public void SetSubjectId(string? subjectId) => metadata.SubjectId = string.IsNullOrWhiteSpace(subjectId) ? null : subjectId;
|
||||
|
||||
public void SetTag(string name, string? value) => metadata.SetTag(name, value);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ public class AuthorityRateLimiterMetadataMiddlewareTests
|
||||
context.Request.Path = "/token";
|
||||
context.Request.Method = HttpMethods.Post;
|
||||
context.Request.Headers["X-Forwarded-For"] = "203.0.113.99";
|
||||
context.Request.Headers.UserAgent = "StellaOps-Client/1.2";
|
||||
|
||||
var middleware = CreateMiddleware();
|
||||
await middleware.InvokeAsync(context);
|
||||
@@ -84,6 +85,9 @@ public class AuthorityRateLimiterMetadataMiddlewareTests
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal("203.0.113.99", metadata!.RemoteIp);
|
||||
Assert.Equal("203.0.113.99", metadata.ForwardedFor);
|
||||
Assert.Equal("StellaOps-Client/1.2", metadata.UserAgent);
|
||||
Assert.True(metadata.Tags.TryGetValue("authority.user_agent", out var tagValue));
|
||||
Assert.Equal("StellaOps-Client/1.2", tagValue);
|
||||
}
|
||||
|
||||
private static AuthorityRateLimiterMetadataMiddleware CreateMiddleware()
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.Bootstrap;
|
||||
|
||||
internal sealed class BootstrapInviteCleanupService : BackgroundService
|
||||
{
|
||||
private readonly IAuthorityBootstrapInviteStore inviteStore;
|
||||
private readonly IAuthEventSink auditSink;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<BootstrapInviteCleanupService> logger;
|
||||
private readonly TimeSpan interval;
|
||||
|
||||
public BootstrapInviteCleanupService(
|
||||
IAuthorityBootstrapInviteStore inviteStore,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<BootstrapInviteCleanupService> logger)
|
||||
{
|
||||
this.inviteStore = inviteStore ?? throw new ArgumentNullException(nameof(inviteStore));
|
||||
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
interval = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var timer = new PeriodicTimer(interval);
|
||||
|
||||
try
|
||||
{
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
|
||||
{
|
||||
await SweepExpiredInvitesAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Shutdown requested.
|
||||
}
|
||||
finally
|
||||
{
|
||||
timer.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task SweepExpiredInvitesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var expired = await inviteStore.ExpireAsync(now, cancellationToken).ConfigureAwait(false);
|
||||
if (expired.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("Expired {Count} bootstrap invite(s).", expired.Count);
|
||||
|
||||
foreach (var invite in expired)
|
||||
{
|
||||
var record = new AuthEventRecord
|
||||
{
|
||||
EventType = "authority.bootstrap.invite.expired",
|
||||
OccurredAt = now,
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
Outcome = AuthEventOutcome.Success,
|
||||
Reason = "Invite expired before consumption.",
|
||||
Subject = null,
|
||||
Client = null,
|
||||
Scopes = Array.Empty<string>(),
|
||||
Network = null,
|
||||
Properties = BuildInviteProperties(invite)
|
||||
};
|
||||
|
||||
await auditSink.WriteAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static AuthEventProperty[] BuildInviteProperties(AuthorityBootstrapInviteDocument invite)
|
||||
{
|
||||
var properties = new List<AuthEventProperty>
|
||||
{
|
||||
new() { Name = "invite.token", Value = ClassifiedString.Public(invite.Token) },
|
||||
new() { Name = "invite.type", Value = ClassifiedString.Public(invite.Type) },
|
||||
new() { Name = "invite.expires_at", Value = ClassifiedString.Public(invite.ExpiresAt.ToString("O", CultureInfo.InvariantCulture)) }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(invite.Provider))
|
||||
{
|
||||
properties.Add(new AuthEventProperty { Name = "invite.provider", Value = ClassifiedString.Public(invite.Provider) });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(invite.Target))
|
||||
{
|
||||
properties.Add(new AuthEventProperty { Name = "invite.target", Value = ClassifiedString.Public(invite.Target) });
|
||||
}
|
||||
|
||||
return properties.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ internal sealed record BootstrapUserRequest
|
||||
{
|
||||
public string? Provider { get; init; }
|
||||
|
||||
public string? InviteToken { get; init; }
|
||||
|
||||
[Required]
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
@@ -27,6 +29,8 @@ internal sealed record BootstrapClientRequest
|
||||
{
|
||||
public string? Provider { get; init; }
|
||||
|
||||
public string? InviteToken { get; init; }
|
||||
|
||||
[Required]
|
||||
public string ClientId { get; init; } = string.Empty;
|
||||
|
||||
@@ -46,3 +50,26 @@ internal sealed record BootstrapClientRequest
|
||||
|
||||
public IReadOnlyDictionary<string, string?>? Properties { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record BootstrapInviteRequest
|
||||
{
|
||||
public string Type { get; init; } = BootstrapInviteTypes.User;
|
||||
|
||||
public string? Token { get; init; }
|
||||
|
||||
public string? Provider { get; init; }
|
||||
|
||||
public string? Target { get; init; }
|
||||
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
public string? IssuedBy { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, string?>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
internal static class BootstrapInviteTypes
|
||||
{
|
||||
public const string User = "user";
|
||||
public const string Client = "client";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
internal static class ClientCredentialsAuditHelper
|
||||
{
|
||||
internal static string EnsureCorrelationId(OpenIddictServerTransaction transaction)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(transaction);
|
||||
|
||||
if (transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditCorrelationProperty, out var value) &&
|
||||
value is string existing &&
|
||||
!string.IsNullOrWhiteSpace(existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var correlation = Activity.Current?.TraceId.ToString() ??
|
||||
Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
|
||||
|
||||
transaction.Properties[AuthorityOpenIddictConstants.AuditCorrelationProperty] = correlation;
|
||||
return correlation;
|
||||
}
|
||||
|
||||
internal static AuthEventRecord CreateRecord(
|
||||
TimeProvider timeProvider,
|
||||
OpenIddictServerTransaction transaction,
|
||||
AuthorityRateLimiterMetadata? metadata,
|
||||
string? clientSecret,
|
||||
AuthEventOutcome outcome,
|
||||
string? reason,
|
||||
string? clientId,
|
||||
string? providerName,
|
||||
bool? confidential,
|
||||
IReadOnlyList<string> requestedScopes,
|
||||
IReadOnlyList<string> grantedScopes,
|
||||
string? invalidScope,
|
||||
IEnumerable<AuthEventProperty>? extraProperties = null,
|
||||
string? eventType = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
ArgumentNullException.ThrowIfNull(transaction);
|
||||
|
||||
var correlationId = EnsureCorrelationId(transaction);
|
||||
var client = BuildClient(clientId, providerName);
|
||||
var network = BuildNetwork(metadata);
|
||||
var normalizedGranted = NormalizeScopes(grantedScopes);
|
||||
var properties = BuildProperties(confidential, requestedScopes, invalidScope, extraProperties);
|
||||
|
||||
return new AuthEventRecord
|
||||
{
|
||||
EventType = string.IsNullOrWhiteSpace(eventType) ? "authority.client_credentials.grant" : eventType,
|
||||
OccurredAt = timeProvider.GetUtcNow(),
|
||||
CorrelationId = correlationId,
|
||||
Outcome = outcome,
|
||||
Reason = Normalize(reason),
|
||||
Subject = null,
|
||||
Client = client,
|
||||
Scopes = normalizedGranted,
|
||||
Network = network,
|
||||
Properties = properties
|
||||
};
|
||||
}
|
||||
|
||||
internal static AuthEventRecord CreateTamperRecord(
|
||||
TimeProvider timeProvider,
|
||||
OpenIddictServerTransaction transaction,
|
||||
AuthorityRateLimiterMetadata? metadata,
|
||||
string? clientId,
|
||||
string? providerName,
|
||||
bool? confidential,
|
||||
IEnumerable<string> unexpectedParameters)
|
||||
{
|
||||
var properties = new List<AuthEventProperty>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "request.tampered",
|
||||
Value = ClassifiedString.Public("true")
|
||||
}
|
||||
};
|
||||
|
||||
if (confidential.HasValue)
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "client.confidential",
|
||||
Value = ClassifiedString.Public(confidential.Value ? "true" : "false")
|
||||
});
|
||||
}
|
||||
|
||||
if (unexpectedParameters is not null)
|
||||
{
|
||||
foreach (var parameter in unexpectedParameters)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(parameter))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "request.unexpected_parameter",
|
||||
Value = ClassifiedString.Public(parameter)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var reason = unexpectedParameters is null
|
||||
? "Unexpected parameters supplied to client credentials request."
|
||||
: $"Unexpected parameters supplied to client credentials request: {string.Join(", ", unexpectedParameters)}.";
|
||||
|
||||
return CreateRecord(
|
||||
timeProvider,
|
||||
transaction,
|
||||
metadata,
|
||||
clientSecret: null,
|
||||
outcome: AuthEventOutcome.Failure,
|
||||
reason: reason,
|
||||
clientId: clientId,
|
||||
providerName: providerName,
|
||||
confidential: confidential,
|
||||
requestedScopes: Array.Empty<string>(),
|
||||
grantedScopes: Array.Empty<string>(),
|
||||
invalidScope: null,
|
||||
extraProperties: properties,
|
||||
eventType: "authority.token.tamper");
|
||||
}
|
||||
|
||||
private static AuthEventClient? BuildClient(string? clientId, string? providerName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clientId) && string.IsNullOrWhiteSpace(providerName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthEventClient
|
||||
{
|
||||
ClientId = ClassifiedString.Personal(Normalize(clientId)),
|
||||
Name = ClassifiedString.Empty,
|
||||
Provider = ClassifiedString.Public(Normalize(providerName))
|
||||
};
|
||||
}
|
||||
|
||||
private static AuthEventNetwork? BuildNetwork(AuthorityRateLimiterMetadata? metadata)
|
||||
{
|
||||
var remote = Normalize(metadata?.RemoteIp);
|
||||
var forwarded = Normalize(metadata?.ForwardedFor);
|
||||
var userAgent = Normalize(metadata?.UserAgent);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(remote) && string.IsNullOrWhiteSpace(forwarded) && string.IsNullOrWhiteSpace(userAgent))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthEventNetwork
|
||||
{
|
||||
RemoteAddress = ClassifiedString.Personal(remote),
|
||||
ForwardedFor = ClassifiedString.Personal(forwarded),
|
||||
UserAgent = ClassifiedString.Personal(userAgent)
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AuthEventProperty> BuildProperties(
|
||||
bool? confidential,
|
||||
IReadOnlyList<string> requestedScopes,
|
||||
string? invalidScope,
|
||||
IEnumerable<AuthEventProperty>? extraProperties)
|
||||
{
|
||||
var properties = new List<AuthEventProperty>();
|
||||
|
||||
if (confidential.HasValue)
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "client.confidential",
|
||||
Value = ClassifiedString.Public(confidential.Value ? "true" : "false")
|
||||
});
|
||||
}
|
||||
|
||||
var normalizedRequested = NormalizeScopes(requestedScopes);
|
||||
if (normalizedRequested is { Count: > 0 })
|
||||
{
|
||||
foreach (var scope in normalizedRequested)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "scope.requested",
|
||||
Value = ClassifiedString.Public(scope)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(invalidScope))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "scope.invalid",
|
||||
Value = ClassifiedString.Public(invalidScope)
|
||||
});
|
||||
}
|
||||
|
||||
if (extraProperties is not null)
|
||||
{
|
||||
foreach (var property in extraProperties)
|
||||
{
|
||||
if (property is null || string.IsNullOrWhiteSpace(property.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
properties.Add(property);
|
||||
}
|
||||
}
|
||||
|
||||
return properties.Count == 0 ? Array.Empty<AuthEventProperty>() : properties;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeScopes(IReadOnlyList<string>? scopes)
|
||||
{
|
||||
if (scopes is null || scopes.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var normalized = scopes
|
||||
.Where(static scope => !string.IsNullOrWhiteSpace(scope))
|
||||
.Select(static scope => scope.Trim())
|
||||
.Where(static scope => scope.Length > 0)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static scope => scope, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return normalized.Length == 0 ? Array.Empty<string>() : normalized;
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
@@ -76,6 +76,22 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
var requestedScopes = requestedScopeInput.IsDefaultOrEmpty ? Array.Empty<string>() : requestedScopeInput.ToArray();
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditRequestedScopesProperty] = requestedScopes;
|
||||
|
||||
var unexpectedParameters = TokenRequestTamperInspector.GetUnexpectedClientCredentialsParameters(context.Request);
|
||||
if (unexpectedParameters.Count > 0)
|
||||
{
|
||||
var providerHint = context.Request.GetParameter(AuthorityOpenIddictConstants.ProviderParameterName)?.Value?.ToString();
|
||||
var tamperRecord = ClientCredentialsAuditHelper.CreateTamperRecord(
|
||||
timeProvider,
|
||||
context.Transaction,
|
||||
metadata,
|
||||
clientId,
|
||||
providerHint,
|
||||
confidential: null,
|
||||
unexpectedParameters);
|
||||
|
||||
await auditSink.WriteAsync(tamperRecord, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(context.ClientId))
|
||||
|
||||
@@ -68,6 +68,23 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
|
||||
var requestedScopesInput = context.Request.GetScopes();
|
||||
var requestedScopes = requestedScopesInput.IsDefaultOrEmpty ? Array.Empty<string>() : requestedScopesInput.ToArray();
|
||||
|
||||
var unexpectedParameters = TokenRequestTamperInspector.GetUnexpectedPasswordGrantParameters(context.Request);
|
||||
if (unexpectedParameters.Count > 0)
|
||||
{
|
||||
var providerHint = context.Request.GetParameter(AuthorityOpenIddictConstants.ProviderParameterName)?.Value?.ToString();
|
||||
var tamperRecord = PasswordGrantAuditHelper.CreateTamperRecord(
|
||||
timeProvider,
|
||||
context.Transaction,
|
||||
metadata,
|
||||
clientId,
|
||||
providerHint,
|
||||
context.Request.Username,
|
||||
requestedScopes,
|
||||
unexpectedParameters);
|
||||
|
||||
await auditSink.WriteAsync(tamperRecord, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var selection = AuthorityIdentityProviderSelector.ResolvePasswordProvider(context.Request, registry);
|
||||
if (!selection.Succeeded)
|
||||
{
|
||||
@@ -75,7 +92,6 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
|
||||
timeProvider,
|
||||
context.Transaction,
|
||||
metadata,
|
||||
null,
|
||||
AuthEventOutcome.Failure,
|
||||
selection.Description,
|
||||
clientId,
|
||||
@@ -100,7 +116,6 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
|
||||
timeProvider,
|
||||
context.Transaction,
|
||||
metadata,
|
||||
httpContext,
|
||||
AuthEventOutcome.Failure,
|
||||
"Both username and password must be provided.",
|
||||
clientId,
|
||||
@@ -250,7 +265,6 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
|
||||
timeProvider,
|
||||
context.Transaction,
|
||||
metadata,
|
||||
httpContext,
|
||||
AuthEventOutcome.Failure,
|
||||
"Both username and password must be provided.",
|
||||
clientId,
|
||||
@@ -395,7 +409,8 @@ internal static class PasswordGrantAuditHelper
|
||||
IEnumerable<string>? scopes,
|
||||
TimeSpan? retryAfter,
|
||||
AuthorityCredentialFailureCode? failureCode,
|
||||
IEnumerable<AuthEventProperty>? extraProperties)
|
||||
IEnumerable<AuthEventProperty>? extraProperties,
|
||||
string? eventType = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
ArgumentNullException.ThrowIfNull(transaction);
|
||||
@@ -409,7 +424,7 @@ internal static class PasswordGrantAuditHelper
|
||||
|
||||
return new AuthEventRecord
|
||||
{
|
||||
EventType = "authority.password.grant",
|
||||
EventType = string.IsNullOrWhiteSpace(eventType) ? "authority.password.grant" : eventType,
|
||||
OccurredAt = timeProvider.GetUtcNow(),
|
||||
CorrelationId = correlationId,
|
||||
Outcome = outcome,
|
||||
@@ -581,4 +596,61 @@ internal static class PasswordGrantAuditHelper
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
internal static AuthEventRecord CreateTamperRecord(
|
||||
TimeProvider timeProvider,
|
||||
OpenIddictServerTransaction transaction,
|
||||
AuthorityRateLimiterMetadata? metadata,
|
||||
string? clientId,
|
||||
string? providerName,
|
||||
string? username,
|
||||
IEnumerable<string>? scopes,
|
||||
IEnumerable<string> unexpectedParameters)
|
||||
{
|
||||
var properties = new List<AuthEventProperty>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "request.tampered",
|
||||
Value = ClassifiedString.Public("true")
|
||||
}
|
||||
};
|
||||
|
||||
if (unexpectedParameters is not null)
|
||||
{
|
||||
foreach (var parameter in unexpectedParameters)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(parameter))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "request.unexpected_parameter",
|
||||
Value = ClassifiedString.Public(parameter)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var reason = unexpectedParameters is null
|
||||
? "Unexpected parameters supplied to password grant request."
|
||||
: $"Unexpected parameters supplied to password grant request: {string.Join(", ", unexpectedParameters)}.";
|
||||
|
||||
return CreatePasswordGrantRecord(
|
||||
timeProvider,
|
||||
transaction,
|
||||
metadata,
|
||||
AuthEventOutcome.Failure,
|
||||
reason,
|
||||
clientId,
|
||||
providerName,
|
||||
user: null,
|
||||
username,
|
||||
scopes,
|
||||
retryAfter: null,
|
||||
failureCode: null,
|
||||
extraProperties: properties,
|
||||
eventType: "authority.token.tamper");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,14 +111,26 @@ internal sealed class HandleRevocationRequestHandler : IOpenIddictServerHandler<
|
||||
|
||||
private static byte[] Base64UrlDecode(string value)
|
||||
{
|
||||
var padded = value.Length % 4 switch
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
2 => value + "==",
|
||||
3 => value + "=",
|
||||
_ => value
|
||||
};
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
|
||||
padded = padded.Replace('-', '+').Replace('_', '/');
|
||||
var remainder = value.Length % 4;
|
||||
if (remainder == 2)
|
||||
{
|
||||
value += "==";
|
||||
}
|
||||
else if (remainder == 3)
|
||||
{
|
||||
value += "=";
|
||||
}
|
||||
else if (remainder != 0)
|
||||
{
|
||||
value += new string('=', 4 - remainder);
|
||||
}
|
||||
|
||||
var padded = value.Replace('-', '+').Replace('_', '/');
|
||||
return Convert.FromBase64String(padded);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddict
|
||||
|
||||
private static DateTimeOffset? TryGetExpiration(ClaimsPrincipal principal)
|
||||
{
|
||||
var value = principal.GetClaim(OpenIddictConstants.Claims.Exp);
|
||||
var value = principal.GetClaim("exp");
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenIddict.Abstractions;
|
||||
@@ -7,8 +10,10 @@ using OpenIddict.Server;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority.OpenIddict;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
@@ -17,6 +22,8 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
|
||||
private readonly IAuthorityTokenStore tokenStore;
|
||||
private readonly IAuthorityClientStore clientStore;
|
||||
private readonly IAuthorityIdentityProviderRegistry registry;
|
||||
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
|
||||
private readonly IAuthEventSink auditSink;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ActivitySource activitySource;
|
||||
private readonly ILogger<ValidateAccessTokenHandler> logger;
|
||||
@@ -25,6 +32,8 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
|
||||
IAuthorityTokenStore tokenStore,
|
||||
IAuthorityClientStore clientStore,
|
||||
IAuthorityIdentityProviderRegistry registry,
|
||||
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider clock,
|
||||
ActivitySource activitySource,
|
||||
ILogger<ValidateAccessTokenHandler> logger)
|
||||
@@ -32,6 +41,8 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
|
||||
this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
|
||||
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
|
||||
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor));
|
||||
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
@@ -63,9 +74,10 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
|
||||
? context.TokenId
|
||||
: context.Principal.GetClaim(OpenIddictConstants.Claims.JwtId);
|
||||
|
||||
AuthorityTokenDocument? tokenDocument = null;
|
||||
if (!string.IsNullOrWhiteSpace(tokenId))
|
||||
{
|
||||
var tokenDocument = await tokenStore.FindByTokenIdAsync(tokenId, context.CancellationToken).ConfigureAwait(false);
|
||||
tokenDocument = await tokenStore.FindByTokenIdAsync(tokenId, context.CancellationToken).ConfigureAwait(false);
|
||||
if (tokenDocument is not null)
|
||||
{
|
||||
if (!string.Equals(tokenDocument.Status, "valid", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -87,6 +99,11 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
|
||||
}
|
||||
}
|
||||
|
||||
if (!context.IsRejected && tokenDocument is not null)
|
||||
{
|
||||
await TrackTokenUsageAsync(context, tokenDocument, context.Principal).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var clientId = context.Principal.GetClaim(OpenIddictConstants.Claims.ClientId);
|
||||
if (!string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
@@ -144,4 +161,107 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
|
||||
identity.GetClaim(OpenIddictConstants.Claims.Subject),
|
||||
identity.GetClaim(OpenIddictConstants.Claims.ClientId));
|
||||
}
|
||||
|
||||
private async ValueTask TrackTokenUsageAsync(
|
||||
OpenIddictServerEvents.ValidateTokenContext context,
|
||||
AuthorityTokenDocument tokenDocument,
|
||||
ClaimsPrincipal principal)
|
||||
{
|
||||
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)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
switch (result.Status)
|
||||
{
|
||||
case TokenUsageUpdateStatus.MissingMetadata:
|
||||
logger.LogDebug("Token usage metadata missing for token {TokenId}; replay detection skipped.", tokenDocument.TokenId);
|
||||
break;
|
||||
case TokenUsageUpdateStatus.NotFound:
|
||||
logger.LogWarning("Token usage recording failed: token {TokenId} not found.", tokenDocument.TokenId);
|
||||
break;
|
||||
case TokenUsageUpdateStatus.Recorded:
|
||||
metadataAccessor.SetTag("authority.token_usage", "recorded");
|
||||
break;
|
||||
case TokenUsageUpdateStatus.SuspectedReplay:
|
||||
metadataAccessor.SetTag("authority.token_usage", "suspected_replay");
|
||||
await EmitReplayAuditAsync(tokenDocument, principal, metadata, result, observedAt, context.CancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask EmitReplayAuditAsync(
|
||||
AuthorityTokenDocument tokenDocument,
|
||||
ClaimsPrincipal principal,
|
||||
AuthorityRateLimiterMetadata? metadata,
|
||||
TokenUsageUpdateResult result,
|
||||
DateTimeOffset observedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var clientId = principal.GetClaim(OpenIddictConstants.Claims.ClientId);
|
||||
var subjectId = principal.GetClaim(OpenIddictConstants.Claims.Subject);
|
||||
var realm = principal.GetClaim(StellaOpsClaimTypes.IdentityProvider);
|
||||
|
||||
var subject = string.IsNullOrWhiteSpace(subjectId) && string.IsNullOrWhiteSpace(realm)
|
||||
? null
|
||||
: new AuthEventSubject
|
||||
{
|
||||
SubjectId = ClassifiedString.Personal(subjectId),
|
||||
Realm = ClassifiedString.Public(string.IsNullOrWhiteSpace(realm) ? null : realm)
|
||||
};
|
||||
|
||||
var client = string.IsNullOrWhiteSpace(clientId)
|
||||
? null
|
||||
: new AuthEventClient
|
||||
{
|
||||
ClientId = ClassifiedString.Personal(clientId)
|
||||
};
|
||||
|
||||
var network = metadata is null && result.RemoteAddress is null && result.UserAgent is null
|
||||
? null
|
||||
: new AuthEventNetwork
|
||||
{
|
||||
RemoteAddress = ClassifiedString.Personal(result.RemoteAddress ?? metadata?.RemoteIp),
|
||||
ForwardedFor = ClassifiedString.Personal(metadata?.ForwardedFor),
|
||||
UserAgent = ClassifiedString.Personal(result.UserAgent ?? metadata?.UserAgent)
|
||||
};
|
||||
|
||||
var previousCount = tokenDocument.Devices?.Count ?? 0;
|
||||
var properties = new List<AuthEventProperty>
|
||||
{
|
||||
new() { Name = "token.id", Value = ClassifiedString.Sensitive(tokenDocument.TokenId) },
|
||||
new() { Name = "token.type", Value = ClassifiedString.Public(tokenDocument.Type) },
|
||||
new() { Name = "token.devices.total", Value = ClassifiedString.Public((previousCount + 1).ToString(CultureInfo.InvariantCulture)) }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tokenDocument.ClientId))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "token.client_id",
|
||||
Value = ClassifiedString.Personal(tokenDocument.ClientId)
|
||||
});
|
||||
}
|
||||
|
||||
logger.LogWarning("Detected suspected token replay for token {TokenId} (client {ClientId}).", tokenDocument.TokenId, clientId ?? "<none>");
|
||||
|
||||
var record = new AuthEventRecord
|
||||
{
|
||||
EventType = "authority.token.replay.suspected",
|
||||
OccurredAt = observedAt,
|
||||
CorrelationId = Activity.Current?.TraceId.ToString() ?? Guid.NewGuid().ToString("N"),
|
||||
Outcome = AuthEventOutcome.Error,
|
||||
Reason = "Token observed from a new device fingerprint.",
|
||||
Subject = subject,
|
||||
Client = client,
|
||||
Scopes = Array.Empty<string>(),
|
||||
Network = network,
|
||||
Properties = properties
|
||||
};
|
||||
|
||||
await auditSink.WriteAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenIddict.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict;
|
||||
|
||||
internal static class TokenRequestTamperInspector
|
||||
{
|
||||
private static readonly HashSet<string> CommonParameters = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
OpenIddictConstants.Parameters.GrantType,
|
||||
OpenIddictConstants.Parameters.Scope,
|
||||
OpenIddictConstants.Parameters.Resource,
|
||||
OpenIddictConstants.Parameters.ClientId,
|
||||
OpenIddictConstants.Parameters.ClientSecret,
|
||||
OpenIddictConstants.Parameters.ClientAssertion,
|
||||
OpenIddictConstants.Parameters.ClientAssertionType,
|
||||
OpenIddictConstants.Parameters.RefreshToken,
|
||||
OpenIddictConstants.Parameters.DeviceCode,
|
||||
OpenIddictConstants.Parameters.Code,
|
||||
OpenIddictConstants.Parameters.CodeVerifier,
|
||||
OpenIddictConstants.Parameters.CodeChallenge,
|
||||
OpenIddictConstants.Parameters.CodeChallengeMethod,
|
||||
OpenIddictConstants.Parameters.RedirectUri,
|
||||
OpenIddictConstants.Parameters.Assertion,
|
||||
OpenIddictConstants.Parameters.Nonce,
|
||||
OpenIddictConstants.Parameters.Prompt,
|
||||
OpenIddictConstants.Parameters.MaxAge,
|
||||
OpenIddictConstants.Parameters.UiLocales,
|
||||
OpenIddictConstants.Parameters.AcrValues,
|
||||
OpenIddictConstants.Parameters.LoginHint,
|
||||
OpenIddictConstants.Parameters.Claims,
|
||||
OpenIddictConstants.Parameters.Token,
|
||||
OpenIddictConstants.Parameters.TokenTypeHint,
|
||||
OpenIddictConstants.Parameters.AccessToken,
|
||||
OpenIddictConstants.Parameters.IdToken
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> PasswordGrantParameters = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
OpenIddictConstants.Parameters.Username,
|
||||
OpenIddictConstants.Parameters.Password,
|
||||
AuthorityOpenIddictConstants.ProviderParameterName
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ClientCredentialsParameters = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
AuthorityOpenIddictConstants.ProviderParameterName
|
||||
};
|
||||
|
||||
internal static IReadOnlyList<string> GetUnexpectedPasswordGrantParameters(OpenIddictRequest request)
|
||||
=> DetectUnexpectedParameters(request, PasswordGrantParameters);
|
||||
|
||||
internal static IReadOnlyList<string> GetUnexpectedClientCredentialsParameters(OpenIddictRequest request)
|
||||
=> DetectUnexpectedParameters(request, ClientCredentialsParameters);
|
||||
|
||||
private static IReadOnlyList<string> DetectUnexpectedParameters(
|
||||
OpenIddictRequest request,
|
||||
HashSet<string> grantSpecific)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var unexpected = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var pair in request.GetParameters())
|
||||
{
|
||||
var name = pair.Key;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsAllowed(name, grantSpecific))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
unexpected.Add(name);
|
||||
}
|
||||
|
||||
return unexpected.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: unexpected
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool IsAllowed(string parameterName, HashSet<string> grantSpecific)
|
||||
{
|
||||
if (CommonParameters.Contains(parameterName) || grantSpecific.Contains(parameterName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parameterName.StartsWith("ext_", StringComparison.OrdinalIgnoreCase) ||
|
||||
parameterName.StartsWith("x-", StringComparison.OrdinalIgnoreCase) ||
|
||||
parameterName.StartsWith("custom_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parameterName.Contains(':', StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ using StellaOps.Authority.Plugins;
|
||||
using StellaOps.Authority.Bootstrap;
|
||||
using StellaOps.Authority.Storage.Mongo.Extensions;
|
||||
using StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Plugin.DependencyInjection;
|
||||
@@ -35,6 +36,7 @@ using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.Authority.Revocation;
|
||||
using StellaOps.Authority.Signing;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -124,6 +126,7 @@ builder.Services.AddSingleton<RevocationBundleBuilder>();
|
||||
builder.Services.AddSingleton<RevocationBundleSigner>();
|
||||
builder.Services.AddSingleton<AuthorityRevocationExportService>();
|
||||
builder.Services.AddSingleton<AuthorityJwksService>();
|
||||
builder.Services.AddHostedService<BootstrapInviteCleanupService>();
|
||||
|
||||
var pluginRegistrationSummary = AuthorityPluginLoader.RegisterPlugins(
|
||||
builder.Services,
|
||||
@@ -281,38 +284,98 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
HttpContext httpContext,
|
||||
BootstrapUserRequest request,
|
||||
IAuthorityIdentityProviderRegistry registry,
|
||||
IAuthorityBootstrapInviteStore inviteStore,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, "Request payload is required.", null, null, null, Array.Empty<string>()).ConfigureAwait(false);
|
||||
await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, "Request payload is required.", null, null, null, Array.Empty<string>(), null).ConfigureAwait(false);
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "Request payload is required." });
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var inviteToken = string.IsNullOrWhiteSpace(request.InviteToken) ? null : request.InviteToken.Trim();
|
||||
AuthorityBootstrapInviteDocument? invite = null;
|
||||
var inviteReserved = false;
|
||||
|
||||
async Task ReleaseInviteAsync(string reason)
|
||||
{
|
||||
if (inviteToken is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (inviteReserved)
|
||||
{
|
||||
await inviteStore.ReleaseAsync(inviteToken, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await WriteInviteAuditAsync("authority.bootstrap.invite.rejected", AuthEventOutcome.Failure, reason, invite, inviteToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (inviteToken is not null)
|
||||
{
|
||||
var reservation = await inviteStore.TryReserveAsync(inviteToken, BootstrapInviteTypes.User, now, request.Username, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
switch (reservation.Status)
|
||||
{
|
||||
case BootstrapInviteReservationStatus.Reserved:
|
||||
inviteReserved = true;
|
||||
invite = reservation.Invite;
|
||||
break;
|
||||
case BootstrapInviteReservationStatus.Expired:
|
||||
await WriteInviteAuditAsync("authority.bootstrap.invite.expired", AuthEventOutcome.Failure, "Invite expired before use.", reservation.Invite, inviteToken).ConfigureAwait(false);
|
||||
return Results.BadRequest(new { error = "invite_expired", message = "Invite has expired." });
|
||||
case BootstrapInviteReservationStatus.AlreadyUsed:
|
||||
await WriteInviteAuditAsync("authority.bootstrap.invite.rejected", AuthEventOutcome.Failure, "Invite token already consumed.", reservation.Invite, inviteToken).ConfigureAwait(false);
|
||||
return Results.BadRequest(new { error = "invite_used", message = "Invite token has already been used." });
|
||||
default:
|
||||
await WriteInviteAuditAsync("authority.bootstrap.invite.rejected", AuthEventOutcome.Failure, "Invite token not found.", reservation.Invite, inviteToken).ConfigureAwait(false);
|
||||
return Results.BadRequest(new { error = "invalid_invite", message = "Invite token is invalid." });
|
||||
}
|
||||
}
|
||||
|
||||
var providerName = string.IsNullOrWhiteSpace(request.Provider)
|
||||
? authorityOptions.Bootstrap.DefaultIdentityProvider
|
||||
? invite?.Provider ?? authorityOptions.Bootstrap.DefaultIdentityProvider
|
||||
: request.Provider;
|
||||
|
||||
if (invite is not null && !string.IsNullOrWhiteSpace(invite.Provider) &&
|
||||
!string.Equals(invite.Provider, providerName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await ReleaseInviteAsync("Invite provider does not match requested provider.");
|
||||
return Results.BadRequest(new { error = "invite_provider_mismatch", message = "Invite is limited to a different identity provider." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(providerName) || !registry.TryGet(providerName!, out var provider))
|
||||
{
|
||||
await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, "Specified identity provider was not found.", null, request.Username, providerName, request.Roles ?? Array.Empty<string>()).ConfigureAwait(false);
|
||||
await ReleaseInviteAsync("Specified identity provider was not found.");
|
||||
await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, "Specified identity provider was not found.", null, request.Username, providerName, request.Roles ?? Array.Empty<string>(), inviteToken).ConfigureAwait(false);
|
||||
return Results.BadRequest(new { error = "invalid_provider", message = "Specified identity provider was not found." });
|
||||
}
|
||||
|
||||
if (!provider.Capabilities.SupportsPassword)
|
||||
{
|
||||
await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, "Selected provider does not support password provisioning.", null, request.Username, provider.Name, request.Roles ?? Array.Empty<string>()).ConfigureAwait(false);
|
||||
await ReleaseInviteAsync("Selected provider does not support password provisioning.");
|
||||
await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, "Selected provider does not support password provisioning.", null, request.Username, provider.Name, request.Roles ?? Array.Empty<string>(), inviteToken).ConfigureAwait(false);
|
||||
return Results.BadRequest(new { error = "unsupported_provider", message = "Selected provider does not support password provisioning." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrEmpty(request.Password))
|
||||
{
|
||||
await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, "Username and password are required.", null, request.Username, provider.Name, request.Roles ?? Array.Empty<string>()).ConfigureAwait(false);
|
||||
await ReleaseInviteAsync("Username and password are required.");
|
||||
await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, "Username and password are required.", null, request.Username, provider.Name, request.Roles ?? Array.Empty<string>(), inviteToken).ConfigureAwait(false);
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "Username and password are required." });
|
||||
}
|
||||
|
||||
if (invite is not null && !string.IsNullOrWhiteSpace(invite.Target) &&
|
||||
!string.Equals(invite.Target, request.Username, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await ReleaseInviteAsync("Invite target does not match requested username.");
|
||||
return Results.BadRequest(new { error = "invite_target_mismatch", message = "Invite target does not match username." });
|
||||
}
|
||||
|
||||
var roles = request.Roles is null ? Array.Empty<string>() : request.Roles.ToArray();
|
||||
var attributes = request.Attributes is null
|
||||
? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -327,24 +390,47 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
roles,
|
||||
attributes);
|
||||
|
||||
var result = await provider.Credentials.UpsertUserAsync(registration, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!result.Succeeded || result.Value is null)
|
||||
try
|
||||
{
|
||||
await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, result.Message ?? "User provisioning failed.", null, request.Username, provider.Name, roles).ConfigureAwait(false);
|
||||
return Results.BadRequest(new { error = result.ErrorCode ?? "bootstrap_failed", message = result.Message ?? "User provisioning failed." });
|
||||
var result = await provider.Credentials.UpsertUserAsync(registration, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!result.Succeeded || result.Value is null)
|
||||
{
|
||||
await ReleaseInviteAsync(result.Message ?? "User provisioning failed.");
|
||||
await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, result.Message ?? "User provisioning failed.", null, request.Username, provider.Name, roles, inviteToken).ConfigureAwait(false);
|
||||
return Results.BadRequest(new { error = result.ErrorCode ?? "bootstrap_failed", message = result.Message ?? "User provisioning failed." });
|
||||
}
|
||||
|
||||
if (inviteReserved && inviteToken is not null)
|
||||
{
|
||||
var consumed = await inviteStore.MarkConsumedAsync(inviteToken, result.Value.SubjectId ?? result.Value.Username, now, cancellationToken).ConfigureAwait(false);
|
||||
if (consumed)
|
||||
{
|
||||
await WriteInviteAuditAsync("authority.bootstrap.invite.consumed", AuthEventOutcome.Success, null, invite, inviteToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
await WriteBootstrapUserAuditAsync(AuthEventOutcome.Success, null, result.Value.SubjectId, result.Value.Username, provider.Name, roles, inviteToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
provider = provider.Name,
|
||||
subjectId = result.Value.SubjectId,
|
||||
username = result.Value.Username
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (inviteReserved && inviteToken is not null)
|
||||
{
|
||||
await inviteStore.ReleaseAsync(inviteToken, cancellationToken).ConfigureAwait(false);
|
||||
await WriteInviteAuditAsync("authority.bootstrap.invite.released", AuthEventOutcome.Error, "Invite released due to provisioning failure.", invite, inviteToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
await WriteBootstrapUserAuditAsync(AuthEventOutcome.Success, null, result.Value.SubjectId, result.Value.Username, provider.Name, roles).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
provider = provider.Name,
|
||||
subjectId = result.Value.SubjectId,
|
||||
username = result.Value.Username
|
||||
});
|
||||
|
||||
async Task WriteBootstrapUserAuditAsync(AuthEventOutcome outcome, string? reason, string? subjectId, string? usernameValue, string? providerValue, IReadOnlyCollection<string> rolesValue)
|
||||
async Task WriteBootstrapUserAuditAsync(AuthEventOutcome outcome, string? reason, string? subjectId, string? usernameValue, string? providerValue, IReadOnlyCollection<string> rolesValue, string? inviteValue)
|
||||
{
|
||||
var correlationId = Activity.Current?.TraceId.ToString() ?? httpContext.TraceIdentifier ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
|
||||
AuthEventNetwork? network = null;
|
||||
@@ -369,16 +455,24 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
Realm = ClassifiedString.Public(providerValue)
|
||||
};
|
||||
|
||||
var properties = string.IsNullOrWhiteSpace(providerValue)
|
||||
? Array.Empty<AuthEventProperty>()
|
||||
: new[]
|
||||
var properties = new List<AuthEventProperty>();
|
||||
if (!string.IsNullOrWhiteSpace(providerValue))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
new AuthEventProperty
|
||||
{
|
||||
Name = "bootstrap.provider",
|
||||
Value = ClassifiedString.Public(providerValue)
|
||||
}
|
||||
};
|
||||
Name = "bootstrap.provider",
|
||||
Value = ClassifiedString.Public(providerValue)
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(inviteValue))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "bootstrap.invite_token",
|
||||
Value = ClassifiedString.Public(inviteValue)
|
||||
});
|
||||
}
|
||||
|
||||
var scopes = rolesValue is { Count: > 0 }
|
||||
? rolesValue.ToArray()
|
||||
@@ -395,65 +489,199 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
Client = null,
|
||||
Scopes = scopes,
|
||||
Network = network,
|
||||
Properties = properties
|
||||
Properties = properties.Count == 0 ? Array.Empty<AuthEventProperty>() : properties
|
||||
};
|
||||
|
||||
await auditSink.WriteAsync(record, httpContext.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
async Task WriteInviteAuditAsync(string eventType, AuthEventOutcome outcome, string? reason, AuthorityBootstrapInviteDocument? document, string? tokenValue)
|
||||
{
|
||||
var record = new AuthEventRecord
|
||||
{
|
||||
EventType = eventType,
|
||||
OccurredAt = timeProvider.GetUtcNow(),
|
||||
CorrelationId = Activity.Current?.TraceId.ToString() ?? httpContext.TraceIdentifier ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture),
|
||||
Outcome = outcome,
|
||||
Reason = reason,
|
||||
Subject = null,
|
||||
Client = null,
|
||||
Scopes = Array.Empty<string>(),
|
||||
Network = null,
|
||||
Properties = BuildInviteProperties(document, tokenValue)
|
||||
};
|
||||
|
||||
await auditSink.WriteAsync(record, httpContext.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
static AuthEventProperty[] BuildInviteProperties(AuthorityBootstrapInviteDocument? document, string? token)
|
||||
{
|
||||
var properties = new List<AuthEventProperty>();
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "invite.token",
|
||||
Value = ClassifiedString.Public(token)
|
||||
});
|
||||
}
|
||||
|
||||
if (document is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(document.Type))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "invite.type",
|
||||
Value = ClassifiedString.Public(document.Type)
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(document.Provider))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "invite.provider",
|
||||
Value = ClassifiedString.Public(document.Provider)
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(document.Target))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "invite.target",
|
||||
Value = ClassifiedString.Public(document.Target)
|
||||
});
|
||||
}
|
||||
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "invite.expires_at",
|
||||
Value = ClassifiedString.Public(document.ExpiresAt.ToString("O", CultureInfo.InvariantCulture))
|
||||
});
|
||||
}
|
||||
|
||||
return properties.Count == 0 ? Array.Empty<AuthEventProperty>() : properties.ToArray();
|
||||
}
|
||||
});
|
||||
|
||||
bootstrapGroup.MapPost("/clients", async (
|
||||
HttpContext httpContext,
|
||||
BootstrapClientRequest request,
|
||||
IAuthorityIdentityProviderRegistry registry,
|
||||
IAuthorityBootstrapInviteStore inviteStore,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Request payload is required.", null, null, null, Array.Empty<string>(), null).ConfigureAwait(false);
|
||||
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Request payload is required.", null, null, null, Array.Empty<string>(), null, null).ConfigureAwait(false);
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "Request payload is required." });
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var inviteToken = string.IsNullOrWhiteSpace(request.InviteToken) ? null : request.InviteToken.Trim();
|
||||
AuthorityBootstrapInviteDocument? invite = null;
|
||||
var inviteReserved = false;
|
||||
|
||||
async Task ReleaseInviteAsync(string reason)
|
||||
{
|
||||
if (inviteToken is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (inviteReserved)
|
||||
{
|
||||
await inviteStore.ReleaseAsync(inviteToken, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await WriteInviteAuditAsync("authority.bootstrap.invite.rejected", AuthEventOutcome.Failure, reason, invite, inviteToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (inviteToken is not null)
|
||||
{
|
||||
var reservation = await inviteStore.TryReserveAsync(inviteToken, BootstrapInviteTypes.Client, now, request.ClientId, cancellationToken).ConfigureAwait(false);
|
||||
switch (reservation.Status)
|
||||
{
|
||||
case BootstrapInviteReservationStatus.Reserved:
|
||||
inviteReserved = true;
|
||||
invite = reservation.Invite;
|
||||
break;
|
||||
case BootstrapInviteReservationStatus.Expired:
|
||||
await WriteInviteAuditAsync("authority.bootstrap.invite.expired", AuthEventOutcome.Failure, "Invite expired before use.", reservation.Invite, inviteToken).ConfigureAwait(false);
|
||||
return Results.BadRequest(new { error = "invite_expired", message = "Invite has expired." });
|
||||
case BootstrapInviteReservationStatus.AlreadyUsed:
|
||||
await WriteInviteAuditAsync("authority.bootstrap.invite.rejected", AuthEventOutcome.Failure, "Invite token already consumed.", reservation.Invite, inviteToken).ConfigureAwait(false);
|
||||
return Results.BadRequest(new { error = "invite_used", message = "Invite token has already been used." });
|
||||
default:
|
||||
await WriteInviteAuditAsync("authority.bootstrap.invite.rejected", AuthEventOutcome.Failure, "Invite token is invalid.", reservation.Invite, inviteToken).ConfigureAwait(false);
|
||||
return Results.BadRequest(new { error = "invalid_invite", message = "Invite token is invalid." });
|
||||
}
|
||||
}
|
||||
|
||||
var providerName = string.IsNullOrWhiteSpace(request.Provider)
|
||||
? authorityOptions.Bootstrap.DefaultIdentityProvider
|
||||
? invite?.Provider ?? authorityOptions.Bootstrap.DefaultIdentityProvider
|
||||
: request.Provider;
|
||||
|
||||
if (invite is not null && !string.IsNullOrWhiteSpace(invite.Provider) &&
|
||||
!string.Equals(invite.Provider, providerName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await ReleaseInviteAsync("Invite provider does not match requested provider.");
|
||||
return Results.BadRequest(new { error = "invite_provider_mismatch", message = "Invite is limited to a different identity provider." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(providerName) || !registry.TryGet(providerName!, out var provider))
|
||||
{
|
||||
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Specified identity provider was not found.", request.ClientId, null, providerName, request.AllowedScopes ?? Array.Empty<string>(), request?.Confidential).ConfigureAwait(false);
|
||||
await ReleaseInviteAsync("Specified identity provider was not found.");
|
||||
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Specified identity provider was not found.", request.ClientId, null, providerName, request.AllowedScopes ?? Array.Empty<string>(), request?.Confidential, inviteToken).ConfigureAwait(false);
|
||||
return Results.BadRequest(new { error = "invalid_provider", message = "Specified identity provider was not found." });
|
||||
}
|
||||
|
||||
if (!provider.Capabilities.SupportsClientProvisioning || provider.ClientProvisioning is null)
|
||||
{
|
||||
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Selected provider does not support client provisioning.", request.ClientId, null, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential).ConfigureAwait(false);
|
||||
await ReleaseInviteAsync("Selected provider does not support client provisioning.");
|
||||
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Selected provider does not support client provisioning.", request.ClientId, null, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
|
||||
return Results.BadRequest(new { error = "unsupported_provider", message = "Selected provider does not support client provisioning." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ClientId))
|
||||
{
|
||||
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "ClientId is required.", null, null, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential).ConfigureAwait(false);
|
||||
await ReleaseInviteAsync("ClientId is required.");
|
||||
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "ClientId is required.", null, null, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "ClientId is required." });
|
||||
}
|
||||
|
||||
if (invite is not null && !string.IsNullOrWhiteSpace(invite.Target) &&
|
||||
!string.Equals(invite.Target, request.ClientId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await ReleaseInviteAsync("Invite target does not match requested client id.");
|
||||
return Results.BadRequest(new { error = "invite_target_mismatch", message = "Invite target does not match client id." });
|
||||
}
|
||||
|
||||
if (request.Confidential && string.IsNullOrWhiteSpace(request.ClientSecret))
|
||||
{
|
||||
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Confidential clients require a client secret.", request.ClientId, null, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential).ConfigureAwait(false);
|
||||
await ReleaseInviteAsync("Confidential clients require a client secret.");
|
||||
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Confidential clients require a client secret.", request.ClientId, null, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "Confidential clients require a client secret." });
|
||||
}
|
||||
|
||||
if (!TryParseUris(request.RedirectUris, out var redirectUris, out var redirectError))
|
||||
{
|
||||
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, redirectError, request.ClientId, null, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential).ConfigureAwait(false);
|
||||
return Results.BadRequest(new { error = "invalid_request", message = redirectError });
|
||||
var errorMessage = redirectError ?? "Redirect URI validation failed.";
|
||||
await ReleaseInviteAsync(errorMessage);
|
||||
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, errorMessage, request.ClientId, null, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
|
||||
return Results.BadRequest(new { error = "invalid_request", message = errorMessage });
|
||||
}
|
||||
|
||||
if (!TryParseUris(request.PostLogoutRedirectUris, out var postLogoutUris, out var postLogoutError))
|
||||
{
|
||||
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, postLogoutError, request.ClientId, null, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential).ConfigureAwait(false);
|
||||
return Results.BadRequest(new { error = "invalid_request", message = postLogoutError });
|
||||
var errorMessage = postLogoutError ?? "Post-logout redirect URI validation failed.";
|
||||
await ReleaseInviteAsync(errorMessage);
|
||||
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, errorMessage, request.ClientId, null, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
|
||||
return Results.BadRequest(new { error = "invalid_request", message = errorMessage });
|
||||
}
|
||||
|
||||
var properties = request.Properties is null
|
||||
@@ -475,11 +703,21 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
|
||||
if (!result.Succeeded || result.Value is null)
|
||||
{
|
||||
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, result.Message ?? "Client provisioning failed.", request.ClientId, result.Value?.ClientId, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential).ConfigureAwait(false);
|
||||
await ReleaseInviteAsync(result.Message ?? "Client provisioning failed.");
|
||||
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, result.Message ?? "Client provisioning failed.", request.ClientId, result.Value?.ClientId, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
|
||||
return Results.BadRequest(new { error = result.ErrorCode ?? "bootstrap_failed", message = result.Message ?? "Client provisioning failed." });
|
||||
}
|
||||
|
||||
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Success, null, request.ClientId, result.Value.ClientId, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential).ConfigureAwait(false);
|
||||
if (inviteReserved && inviteToken is not null)
|
||||
{
|
||||
var consumed = await inviteStore.MarkConsumedAsync(inviteToken, result.Value.ClientId, now, cancellationToken).ConfigureAwait(false);
|
||||
if (consumed)
|
||||
{
|
||||
await WriteInviteAuditAsync("authority.bootstrap.invite.consumed", AuthEventOutcome.Success, null, invite, inviteToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Success, null, request.ClientId, result.Value.ClientId, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
@@ -488,7 +726,7 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
confidential = result.Value.Confidential
|
||||
});
|
||||
|
||||
async Task WriteBootstrapClientAuditAsync(AuthEventOutcome outcome, string? reason, string? requestedClientId, string? assignedClientId, string? providerValue, IReadOnlyCollection<string> scopes, bool? confidentialFlag)
|
||||
async Task WriteBootstrapClientAuditAsync(AuthEventOutcome outcome, string? reason, string? requestedClientId, string? assignedClientId, string? providerValue, IReadOnlyCollection<string> scopes, bool? confidentialFlag, string? inviteValue)
|
||||
{
|
||||
var correlationId = Activity.Current?.TraceId.ToString() ?? httpContext.TraceIdentifier ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
|
||||
AuthEventNetwork? network = null;
|
||||
@@ -533,6 +771,15 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(inviteValue))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "bootstrap.invite_token",
|
||||
Value = ClassifiedString.Public(inviteValue)
|
||||
});
|
||||
}
|
||||
|
||||
var record = new AuthEventRecord
|
||||
{
|
||||
EventType = "authority.bootstrap.client",
|
||||
@@ -549,6 +796,175 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
|
||||
await auditSink.WriteAsync(record, httpContext.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
async Task WriteInviteAuditAsync(string eventType, AuthEventOutcome outcome, string? reason, AuthorityBootstrapInviteDocument? document, string? tokenValue)
|
||||
{
|
||||
var record = new AuthEventRecord
|
||||
{
|
||||
EventType = eventType,
|
||||
OccurredAt = timeProvider.GetUtcNow(),
|
||||
CorrelationId = Activity.Current?.TraceId.ToString() ?? httpContext.TraceIdentifier ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture),
|
||||
Outcome = outcome,
|
||||
Reason = reason,
|
||||
Subject = null,
|
||||
Client = null,
|
||||
Scopes = Array.Empty<string>(),
|
||||
Network = null,
|
||||
Properties = BuildInviteProperties(document, tokenValue)
|
||||
};
|
||||
|
||||
await auditSink.WriteAsync(record, httpContext.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
static AuthEventProperty[] BuildInviteProperties(AuthorityBootstrapInviteDocument? document, string? token)
|
||||
{
|
||||
var properties = new List<AuthEventProperty>();
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "invite.token",
|
||||
Value = ClassifiedString.Public(token)
|
||||
});
|
||||
}
|
||||
|
||||
if (document is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(document.Type))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "invite.type",
|
||||
Value = ClassifiedString.Public(document.Type)
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(document.Provider))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "invite.provider",
|
||||
Value = ClassifiedString.Public(document.Provider)
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(document.Target))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "invite.target",
|
||||
Value = ClassifiedString.Public(document.Target)
|
||||
});
|
||||
}
|
||||
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "invite.expires_at",
|
||||
Value = ClassifiedString.Public(document.ExpiresAt.ToString("O", CultureInfo.InvariantCulture))
|
||||
});
|
||||
}
|
||||
|
||||
return properties.Count == 0 ? Array.Empty<AuthEventProperty>() : properties.ToArray();
|
||||
}
|
||||
});
|
||||
bootstrapGroup.MapPost("/invites", async (
|
||||
HttpContext httpContext,
|
||||
BootstrapInviteRequest request,
|
||||
IAuthorityBootstrapInviteStore inviteStore,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "Request payload is required." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Type) ||
|
||||
( !string.Equals(request.Type, BootstrapInviteTypes.User, StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(request.Type, BootstrapInviteTypes.Client, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "Invite type must be 'user' or 'client'." });
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var expiresAt = request.ExpiresAt ?? now.AddDays(2);
|
||||
if (expiresAt <= now)
|
||||
{
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "ExpiresAt must be in the future." });
|
||||
}
|
||||
|
||||
var token = string.IsNullOrWhiteSpace(request.Token) ? Guid.NewGuid().ToString("N") : request.Token.Trim();
|
||||
|
||||
var document = new AuthorityBootstrapInviteDocument
|
||||
{
|
||||
Token = token,
|
||||
Type = request.Type.ToLowerInvariant(),
|
||||
Provider = string.IsNullOrWhiteSpace(request.Provider) ? null : request.Provider.Trim(),
|
||||
Target = string.IsNullOrWhiteSpace(request.Target) ? null : request.Target.Trim(),
|
||||
IssuedAt = now,
|
||||
IssuedBy = string.IsNullOrWhiteSpace(request.IssuedBy) ? httpContext.User?.Identity?.Name : request.IssuedBy,
|
||||
ExpiresAt = expiresAt,
|
||||
Metadata = request.Metadata is null ? null : new Dictionary<string, string?>(request.Metadata, StringComparer.OrdinalIgnoreCase)
|
||||
};
|
||||
|
||||
await inviteStore.CreateAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
await WriteInviteAuditAsync("authority.bootstrap.invite.created", AuthEventOutcome.Success, null, document).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
document.Token,
|
||||
document.Type,
|
||||
document.Provider,
|
||||
document.Target,
|
||||
document.ExpiresAt
|
||||
});
|
||||
|
||||
async Task WriteInviteAuditAsync(string eventType, AuthEventOutcome outcome, string? reason, AuthorityBootstrapInviteDocument invite)
|
||||
{
|
||||
var record = new AuthEventRecord
|
||||
{
|
||||
EventType = eventType,
|
||||
OccurredAt = timeProvider.GetUtcNow(),
|
||||
CorrelationId = Activity.Current?.TraceId.ToString() ?? httpContext.TraceIdentifier ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture),
|
||||
Outcome = outcome,
|
||||
Reason = reason,
|
||||
Subject = null,
|
||||
Client = null,
|
||||
Scopes = Array.Empty<string>(),
|
||||
Network = null,
|
||||
Properties = BuildInviteProperties(invite)
|
||||
};
|
||||
|
||||
await auditSink.WriteAsync(record, httpContext.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
static AuthEventProperty[] BuildInviteProperties(AuthorityBootstrapInviteDocument invite)
|
||||
{
|
||||
var properties = new List<AuthEventProperty>
|
||||
{
|
||||
new() { Name = "invite.token", Value = ClassifiedString.Public(invite.Token) },
|
||||
new() { Name = "invite.type", Value = ClassifiedString.Public(invite.Type) },
|
||||
new() { Name = "invite.expires_at", Value = ClassifiedString.Public(invite.ExpiresAt.ToString("O", CultureInfo.InvariantCulture)) }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(invite.Provider))
|
||||
{
|
||||
properties.Add(new AuthEventProperty { Name = "invite.provider", Value = ClassifiedString.Public(invite.Provider) });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(invite.Target))
|
||||
{
|
||||
properties.Add(new AuthEventProperty { Name = "invite.target", Value = ClassifiedString.Public(invite.Target) });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(invite.IssuedBy))
|
||||
{
|
||||
properties.Add(new AuthEventProperty { Name = "invite.issued_by", Value = ClassifiedString.Public(invite.IssuedBy) });
|
||||
}
|
||||
|
||||
return properties.ToArray();
|
||||
}
|
||||
});
|
||||
|
||||
bootstrapGroup.MapGet("/revocations/export", async (
|
||||
@@ -573,6 +989,7 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
{
|
||||
Algorithm = package.Signature.Algorithm,
|
||||
KeyId = package.Signature.KeyId,
|
||||
Provider = package.Signature.Provider,
|
||||
Value = package.Signature.Value
|
||||
},
|
||||
Digest = new RevocationExportDigest
|
||||
|
||||
@@ -41,6 +41,11 @@ internal sealed class AuthorityRateLimiterMetadata
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Tags => tags;
|
||||
|
||||
/// <summary>
|
||||
/// User agent string associated with the request, if captured.
|
||||
/// </summary>
|
||||
public string? UserAgent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates an arbitrary metadata tag for downstream consumers.
|
||||
/// </summary>
|
||||
|
||||
@@ -61,6 +61,9 @@ internal sealed class AuthorityRateLimiterMetadataMiddleware
|
||||
metadata.ClientId = ResolveAuthorizeClientId(context.Request.Query);
|
||||
}
|
||||
|
||||
var userAgent = NormalizeUserAgent(context.Request.Headers.UserAgent.ToString());
|
||||
metadata.UserAgent = userAgent;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.ClientId))
|
||||
{
|
||||
metadata.SetTag("authority.client_id", metadata.ClientId);
|
||||
@@ -74,6 +77,10 @@ internal sealed class AuthorityRateLimiterMetadataMiddleware
|
||||
metadata.SetTag("authority.endpoint", metadata.Endpoint ?? string.Empty);
|
||||
metadata.SetTag("authority.remote_ip", metadata.RemoteIp ?? "unknown");
|
||||
metadata.SetTag("authority.captured_at", clock.GetUtcNow().ToString("O", CultureInfo.InvariantCulture));
|
||||
if (!string.IsNullOrWhiteSpace(userAgent))
|
||||
{
|
||||
metadata.SetTag("authority.user_agent", userAgent);
|
||||
}
|
||||
|
||||
await next(context).ConfigureAwait(false);
|
||||
}
|
||||
@@ -145,6 +152,17 @@ internal sealed class AuthorityRateLimiterMetadataMiddleware
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? NormalizeUserAgent(string? userAgent)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userAgent))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = userAgent.Trim();
|
||||
return trimmed.Length == 0 ? null : trimmed;
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveTokenClientIdAsync(HttpContext context)
|
||||
{
|
||||
var request = context.Request;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace StellaOps.Authority.Revocation;
|
||||
|
||||
internal sealed record RevocationBundleSignature(string Algorithm, string KeyId, string Value);
|
||||
internal sealed record RevocationBundleSignature(string Algorithm, string KeyId, string Provider, string Value);
|
||||
|
||||
@@ -51,12 +51,18 @@ internal sealed class RevocationBundleSigner
|
||||
: signing.Algorithm.Trim();
|
||||
|
||||
var keyReference = new CryptoKeyReference(signing.ActiveKeyId, signing.Provider);
|
||||
var signer = providerRegistry.ResolveSigner(CryptoCapability.Signing, algorithm, keyReference, signing.Provider);
|
||||
var resolved = providerRegistry.ResolveSigner(
|
||||
CryptoCapability.Signing,
|
||||
algorithm,
|
||||
keyReference,
|
||||
signing.Provider);
|
||||
var signer = resolved.Signer;
|
||||
|
||||
var header = new Dictionary<string, object>
|
||||
{
|
||||
["alg"] = algorithm,
|
||||
["kid"] = signing.ActiveKeyId,
|
||||
["provider"] = resolved.ProviderName,
|
||||
["typ"] = "application/vnd.stellaops.revocation-bundle+jws",
|
||||
["b64"] = false,
|
||||
["crit"] = new[] { "b64" }
|
||||
@@ -77,7 +83,11 @@ internal sealed class RevocationBundleSigner
|
||||
var signingInput = new ReadOnlyMemory<byte>(buffer, 0, signingInputLength);
|
||||
var signatureBytes = await signer.SignAsync(signingInput, cancellationToken).ConfigureAwait(false);
|
||||
var encodedSignature = Base64UrlEncode(signatureBytes);
|
||||
return new RevocationBundleSignature(algorithm, signing.ActiveKeyId, string.Concat(protectedHeader, "..", encodedSignature));
|
||||
return new RevocationBundleSignature(
|
||||
algorithm,
|
||||
signing.ActiveKeyId,
|
||||
resolved.ProviderName,
|
||||
string.Concat(protectedHeader, "..", encodedSignature));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -56,6 +56,9 @@ internal sealed class RevocationExportSignature
|
||||
[JsonPropertyName("keyId")]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("provider")]
|
||||
public required string Provider { get; init; }
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public required string Value { get; init; }
|
||||
}
|
||||
|
||||
@@ -236,10 +236,11 @@ internal sealed class AuthoritySigningKeyManager
|
||||
["status"] = AuthoritySigningKeyStatus.Retired
|
||||
};
|
||||
|
||||
var privateParameters = previous.Key.PrivateParameters;
|
||||
var retiredKey = new CryptoSigningKey(
|
||||
previous.Key.Reference,
|
||||
previous.Key.AlgorithmId,
|
||||
in previous.Key.PrivateParameters,
|
||||
in privateParameters,
|
||||
previous.Key.CreatedAt,
|
||||
previous.Key.ExpiresAt,
|
||||
metadata);
|
||||
|
||||
@@ -6,11 +6,18 @@
|
||||
| CORE9.REVOCATION | DONE (2025-10-12) | Authority Core, Security Guild | CORE5 | Implement revocation list persistence + export hooks (API + CLI). | ✅ Revoked tokens denied; ✅ Export endpoint/CLI returns manifest; ✅ Tests cover offline bundle flow. |
|
||||
| CORE10.JWKS | DONE (2025-10-12) | Authority Core, DevOps | CORE9.REVOCATION | Provide JWKS rotation with pluggable key loader + documentation. | ✅ Signing/encryption keys rotate without downtime; ✅ JWKS endpoint updates; ✅ Docs describe rotation SOP. |
|
||||
| CORE8.RL | DONE (2025-10-12) | Authority Core | CORE8 | Deliver ASP.NET rate limiter plumbing (request metadata, dependency injection hooks) needed by Security Guild. | ✅ `/token` & `/authorize` pipelines expose limiter hooks; ✅ Tests cover throttle behaviour baseline. |
|
||||
| SEC2.HOST | TODO | Security Guild, Authority Core | SEC2.A (audit contract) | Hook audit logger into OpenIddict handlers and bootstrap endpoints. | ✅ Audit events populated with correlationId, IP, client_id; ✅ Mongo login attempts persisted; ✅ Tests verify on success/failure/lockout. |
|
||||
| SEC2.HOST | DONE (2025-10-12) | Security Guild, Authority Core | SEC2.A (audit contract) | Hook audit logger into OpenIddict handlers and bootstrap endpoints. | ✅ Audit events populated with correlationId, IP, client_id; ✅ Mongo login attempts persisted; ✅ Tests verify on success/failure/lockout. |
|
||||
| SEC3.HOST | DONE (2025-10-11) | Security Guild | CORE8.RL, SEC3.A (rate policy) | Apply rate limiter policies (`AddRateLimiter`) to `/token` and `/internal/*` endpoints with configuration binding. | ✅ Policies configurable via `StellaOpsAuthorityOptions.Security.RateLimiting`; ✅ Integration tests hit 429 after limit; ✅ Docs updated. |
|
||||
| SEC4.HOST | DONE (2025-10-12) | Security Guild, DevOps | SEC4.A (revocation schema) | Implement CLI/HTTP surface to export revocation bundle + detached JWS using `StellaOps.Cryptography`. | ✅ `stellaops auth revoke export` CLI/endpoint returns JSON + `.jws`; ✅ Verification script passes; ✅ Operator docs updated. |
|
||||
| SEC4.KEY | DONE (2025-10-12) | Security Guild, DevOps | SEC4.HOST | Integrate signing keys with provider registry (initial ES256). | ✅ Keys loaded via `ICryptoProvider` signer; ✅ Rotation SOP documented. |
|
||||
| SEC5.HOST | TODO | Security Guild | SEC5.A (threat model) | Feed Authority-specific mitigations (rate limiting, audit, revocation) into threat model + backlog. | ✅ Threat model updated; ✅ Backlog issues reference mitigations; ✅ Review sign-off captured. |
|
||||
| SEC5.HOST | DONE (2025-10-14) | Security Guild | SEC5.A (threat model) | Feed Authority-specific mitigations (rate limiting, audit, revocation) into threat model + backlog. | ✅ Threat model updated; ✅ Backlog issues reference mitigations; ✅ Review sign-off captured. |
|
||||
| SEC5.HOST-INVITES | DONE (2025-10-14) | Security Guild, Authority Core | SEC5.D | Implement bootstrap invite persistence, APIs, and background cleanup with audit coverage. | ✅ Invite store + endpoints complete; ✅ Cleanup service expires unused invites; ✅ Audit events for create/consume/expire; ✅ Build/tests green. |
|
||||
> Remark (2025-10-14): Background sweep emits invite expiry audits; integration test added.
|
||||
| SEC5.HOST-REPLAY | DONE (2025-10-14) | Security Guild, Zastava | SEC5.E | Persist token usage metadata and surface suspected replay heuristics. | ✅ Validation handlers record device metadata; ✅ Suspected replay flagged via audit/logs; ✅ Tests cover regression cases. |
|
||||
> Remark (2025-10-14): Token validation handler logs suspected replay audits with device metadata; coverage via unit/integration tests.
|
||||
| SEC3.BUILD | DONE (2025-10-11) | Authority Core, Security Guild | SEC3.HOST, FEEDMERGE-COORD-02-900 | Track normalized-range dependency fallout and restore full test matrix once Feedser range primitives land. | ✅ Feedser normalized range libraries merged; ✅ Authority + Configuration test suites (`dotnet test src/StellaOps.Authority.sln`, `dotnet test src/StellaOps.Configuration.Tests/StellaOps.Configuration.Tests.csproj`) pass without Feedser compile failures; ✅ Status recorded here/Sprints (authority-core broadcast not available). |
|
||||
| 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. |
|
||||
|
||||
> 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