up
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
using StellaOps.Authority.Plugin.Standard.Security;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Tests.Security;
|
||||
|
||||
public class CryptoPasswordHasherTests
|
||||
{
|
||||
[Fact]
|
||||
public void Hash_EmitsArgon2idByDefault()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
var hasher = new CryptoPasswordHasher(options, new DefaultCryptoProvider());
|
||||
|
||||
var encoded = hasher.Hash("Secr3t!");
|
||||
|
||||
Assert.StartsWith("$argon2id$", encoded, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_ReturnsSuccess_ForCurrentAlgorithm()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
var provider = new DefaultCryptoProvider();
|
||||
var hasher = new CryptoPasswordHasher(options, provider);
|
||||
var encoded = hasher.Hash("Passw0rd!");
|
||||
|
||||
var result = hasher.Verify("Passw0rd!", encoded);
|
||||
|
||||
Assert.Equal(PasswordVerificationResult.Success, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_FlagsLegacyPbkdf2_ForRehash()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
var provider = new DefaultCryptoProvider();
|
||||
var hasher = new CryptoPasswordHasher(options, provider);
|
||||
|
||||
var legacy = new Pbkdf2PasswordHasher().Hash(
|
||||
"Passw0rd!",
|
||||
new PasswordHashOptions
|
||||
{
|
||||
Algorithm = PasswordHashAlgorithm.Pbkdf2,
|
||||
Iterations = 150_000
|
||||
});
|
||||
|
||||
var result = hasher.Verify("Passw0rd!", legacy);
|
||||
|
||||
Assert.Equal(PasswordVerificationResult.SuccessRehashNeeded, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_RejectsTamperedPayload()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
var provider = new DefaultCryptoProvider();
|
||||
var hasher = new CryptoPasswordHasher(options, provider);
|
||||
|
||||
var legacy = new Pbkdf2PasswordHasher().Hash(
|
||||
"Passw0rd!",
|
||||
new PasswordHashOptions
|
||||
{
|
||||
Algorithm = PasswordHashAlgorithm.Pbkdf2,
|
||||
Iterations = 160_000
|
||||
});
|
||||
|
||||
var tampered = legacy + "corrupted";
|
||||
|
||||
var result = hasher.Verify("Passw0rd!", tampered);
|
||||
|
||||
Assert.Equal(PasswordVerificationResult.Failed, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_AllowsLegacyAlgorithmWhenConfigured()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
options.PasswordHashing = options.PasswordHashing with
|
||||
{
|
||||
Algorithm = PasswordHashAlgorithm.Pbkdf2,
|
||||
Iterations = 200_000
|
||||
};
|
||||
|
||||
var provider = new DefaultCryptoProvider();
|
||||
var hasher = new CryptoPasswordHasher(options, provider);
|
||||
|
||||
var legacy = new Pbkdf2PasswordHasher().Hash(
|
||||
"Passw0rd!",
|
||||
new PasswordHashOptions
|
||||
{
|
||||
Algorithm = PasswordHashAlgorithm.Pbkdf2,
|
||||
Iterations = 200_000
|
||||
});
|
||||
|
||||
var result = hasher.Verify("Passw0rd!", legacy);
|
||||
|
||||
Assert.Equal(PasswordVerificationResult.Success, result);
|
||||
}
|
||||
|
||||
private static StandardPluginOptions CreateOptions() => new()
|
||||
{
|
||||
PasswordPolicy = new PasswordPolicyOptions
|
||||
{
|
||||
MinimumLength = 8,
|
||||
RequireDigit = true,
|
||||
RequireLowercase = true,
|
||||
RequireUppercase = true,
|
||||
RequireSymbol = false
|
||||
},
|
||||
Lockout = new LockoutOptions
|
||||
{
|
||||
Enabled = true,
|
||||
MaxAttempts = 5,
|
||||
WindowMinutes = 15
|
||||
},
|
||||
PasswordHashing = new PasswordHashOptions
|
||||
{
|
||||
Algorithm = PasswordHashAlgorithm.Argon2id,
|
||||
MemorySizeInKib = 8 * 1024,
|
||||
Iterations = 2,
|
||||
Parallelism = 1
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using StellaOps.Authority.Plugin.Standard;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Tests;
|
||||
|
||||
@@ -96,4 +97,49 @@ public class StandardPluginOptionsTests
|
||||
|
||||
Assert.Equal(Path.GetFullPath(absolute), options.TokenSigning.KeyDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_WhenPasswordHashingMemoryInvalid()
|
||||
{
|
||||
var options = new StandardPluginOptions
|
||||
{
|
||||
PasswordHashing = new PasswordHashOptions
|
||||
{
|
||||
MemorySizeInKib = 0
|
||||
}
|
||||
};
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard"));
|
||||
Assert.Contains("memory", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_WhenPasswordHashingIterationsInvalid()
|
||||
{
|
||||
var options = new StandardPluginOptions
|
||||
{
|
||||
PasswordHashing = new PasswordHashOptions
|
||||
{
|
||||
Iterations = 0
|
||||
}
|
||||
};
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard"));
|
||||
Assert.Contains("iteration", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_WhenPasswordHashingParallelismInvalid()
|
||||
{
|
||||
var options = new StandardPluginOptions
|
||||
{
|
||||
PasswordHashing = new PasswordHashOptions
|
||||
{
|
||||
Parallelism = 0
|
||||
}
|
||||
};
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard"));
|
||||
Assert.Contains("parallelism", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ public class StandardPluginRegistrarTests
|
||||
["passwordPolicy:requireDigit"] = "false",
|
||||
["passwordPolicy:requireSymbol"] = "false",
|
||||
["lockout:enabled"] = "false",
|
||||
["passwordHashing:memorySizeInKib"] = "8192",
|
||||
["passwordHashing:iterations"] = "2",
|
||||
["passwordHashing:parallelism"] = "1",
|
||||
["bootstrapUser:username"] = "bootstrap",
|
||||
["bootstrapUser:password"] = "Bootstrap1!",
|
||||
["bootstrapUser:requirePasswordReset"] = "true"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -7,6 +9,7 @@ using MongoDB.Driver;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Standard.Security;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Tests;
|
||||
|
||||
@@ -37,13 +40,21 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
Enabled = true,
|
||||
MaxAttempts = 2,
|
||||
WindowMinutes = 1
|
||||
},
|
||||
PasswordHashing = new PasswordHashOptions
|
||||
{
|
||||
Algorithm = PasswordHashAlgorithm.Argon2id,
|
||||
MemorySizeInKib = 8 * 1024,
|
||||
Iterations = 2,
|
||||
Parallelism = 1
|
||||
}
|
||||
};
|
||||
var cryptoProvider = new DefaultCryptoProvider();
|
||||
store = new StandardUserCredentialStore(
|
||||
"standard",
|
||||
database,
|
||||
options,
|
||||
new Pbkdf2PasswordHasher(),
|
||||
new CryptoPasswordHasher(options, cryptoProvider),
|
||||
NullLogger<StandardUserCredentialStore>.Instance);
|
||||
}
|
||||
|
||||
@@ -65,6 +76,7 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
var result = await store.VerifyPasswordAsync("alice", "Password1!", CancellationToken.None);
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal("alice", result.User?.Username);
|
||||
Assert.Empty(result.AuditProperties);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -90,6 +102,46 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
|
||||
Assert.Equal(AuthorityCredentialFailureCode.LockedOut, second.FailureCode);
|
||||
Assert.NotNull(second.RetryAfter);
|
||||
Assert.True(second.RetryAfter.Value > System.TimeSpan.Zero);
|
||||
Assert.Contains(second.AuditProperties, property => property.Name == "plugin.lockout_until");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyPasswordAsync_RehashesLegacyHashesToArgon2()
|
||||
{
|
||||
var legacyHash = new Pbkdf2PasswordHasher().Hash(
|
||||
"Legacy1!",
|
||||
new PasswordHashOptions
|
||||
{
|
||||
Algorithm = PasswordHashAlgorithm.Pbkdf2,
|
||||
Iterations = 160_000
|
||||
});
|
||||
|
||||
var document = new StandardUserDocument
|
||||
{
|
||||
Username = "legacy",
|
||||
NormalizedUsername = "legacy",
|
||||
PasswordHash = legacyHash,
|
||||
Roles = new List<string>(),
|
||||
Attributes = new Dictionary<string, string?>(),
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
UpdatedAt = DateTimeOffset.UtcNow.AddDays(-1)
|
||||
};
|
||||
|
||||
await database.GetCollection<StandardUserDocument>("authority_users_standard")
|
||||
.InsertOneAsync(document);
|
||||
|
||||
var result = await store.VerifyPasswordAsync("legacy", "Legacy1!", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal("legacy", result.User?.Username);
|
||||
Assert.Contains(result.AuditProperties, property => property.Name == "plugin.rehashed");
|
||||
|
||||
var updated = await database.GetCollection<StandardUserDocument>("authority_users_standard")
|
||||
.Find(u => u.NormalizedUsername == "legacy")
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
Assert.NotNull(updated);
|
||||
Assert.StartsWith("$argon2id$", updated!.PasswordHash, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Security;
|
||||
|
||||
@@ -18,96 +17,70 @@ internal enum PasswordVerificationResult
|
||||
SuccessRehashNeeded
|
||||
}
|
||||
|
||||
internal sealed class Pbkdf2PasswordHasher : IPasswordHasher
|
||||
internal sealed class CryptoPasswordHasher : IPasswordHasher
|
||||
{
|
||||
private const int SaltSize = 16;
|
||||
private const int HashSize = 32;
|
||||
private const int Iterations = 210_000;
|
||||
private const string Header = "PBKDF2";
|
||||
private readonly StandardPluginOptions options;
|
||||
private readonly ICryptoProvider cryptoProvider;
|
||||
|
||||
public CryptoPasswordHasher(StandardPluginOptions options, ICryptoProvider cryptoProvider)
|
||||
{
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.cryptoProvider = cryptoProvider ?? throw new ArgumentNullException(nameof(cryptoProvider));
|
||||
}
|
||||
|
||||
public string Hash(string password)
|
||||
{
|
||||
if (string.IsNullOrEmpty(password))
|
||||
{
|
||||
throw new ArgumentException("Password is required.", nameof(password));
|
||||
}
|
||||
ArgumentException.ThrowIfNullOrEmpty(password);
|
||||
|
||||
Span<byte> salt = stackalloc byte[SaltSize];
|
||||
RandomNumberGenerator.Fill(salt);
|
||||
var hashOptions = options.PasswordHashing;
|
||||
hashOptions.Validate();
|
||||
|
||||
Span<byte> hash = stackalloc byte[HashSize];
|
||||
var derived = Rfc2898DeriveBytes.Pbkdf2(password, salt.ToArray(), Iterations, HashAlgorithmName.SHA256, HashSize);
|
||||
derived.CopyTo(hash);
|
||||
|
||||
var payload = new byte[1 + SaltSize + HashSize];
|
||||
payload[0] = 0x01; // version
|
||||
salt.CopyTo(payload.AsSpan(1));
|
||||
hash.CopyTo(payload.AsSpan(1 + SaltSize));
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.Append(Header);
|
||||
builder.Append('.');
|
||||
builder.Append(Iterations);
|
||||
builder.Append('.');
|
||||
builder.Append(Convert.ToBase64String(payload));
|
||||
return builder.ToString();
|
||||
var hasher = cryptoProvider.GetPasswordHasher(hashOptions.Algorithm.ToAlgorithmId());
|
||||
return hasher.Hash(password, hashOptions);
|
||||
}
|
||||
|
||||
public PasswordVerificationResult Verify(string password, string hashedPassword)
|
||||
{
|
||||
if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(hashedPassword))
|
||||
ArgumentException.ThrowIfNullOrEmpty(password);
|
||||
ArgumentException.ThrowIfNullOrEmpty(hashedPassword);
|
||||
|
||||
var desired = options.PasswordHashing;
|
||||
desired.Validate();
|
||||
|
||||
var primaryHasher = cryptoProvider.GetPasswordHasher(desired.Algorithm.ToAlgorithmId());
|
||||
|
||||
if (IsArgon2Hash(hashedPassword))
|
||||
{
|
||||
return PasswordVerificationResult.Failed;
|
||||
if (!primaryHasher.Verify(password, hashedPassword))
|
||||
{
|
||||
return PasswordVerificationResult.Failed;
|
||||
}
|
||||
|
||||
return primaryHasher.NeedsRehash(hashedPassword, desired)
|
||||
? PasswordVerificationResult.SuccessRehashNeeded
|
||||
: PasswordVerificationResult.Success;
|
||||
}
|
||||
|
||||
var parts = hashedPassword.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 3 || !string.Equals(parts[0], Header, StringComparison.Ordinal))
|
||||
if (IsLegacyPbkdf2Hash(hashedPassword))
|
||||
{
|
||||
return PasswordVerificationResult.Failed;
|
||||
var legacyHasher = cryptoProvider.GetPasswordHasher(PasswordHashAlgorithm.Pbkdf2.ToAlgorithmId());
|
||||
if (!legacyHasher.Verify(password, hashedPassword))
|
||||
{
|
||||
return PasswordVerificationResult.Failed;
|
||||
}
|
||||
|
||||
return desired.Algorithm == PasswordHashAlgorithm.Pbkdf2 &&
|
||||
!legacyHasher.NeedsRehash(hashedPassword, desired)
|
||||
? PasswordVerificationResult.Success
|
||||
: PasswordVerificationResult.SuccessRehashNeeded;
|
||||
}
|
||||
|
||||
if (!int.TryParse(parts[1], out var iterations))
|
||||
{
|
||||
return PasswordVerificationResult.Failed;
|
||||
}
|
||||
|
||||
byte[] payload;
|
||||
try
|
||||
{
|
||||
payload = Convert.FromBase64String(parts[2]);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return PasswordVerificationResult.Failed;
|
||||
}
|
||||
|
||||
if (payload.Length != 1 + SaltSize + HashSize)
|
||||
{
|
||||
return PasswordVerificationResult.Failed;
|
||||
}
|
||||
|
||||
var version = payload[0];
|
||||
if (version != 0x01)
|
||||
{
|
||||
return PasswordVerificationResult.Failed;
|
||||
}
|
||||
|
||||
var salt = new byte[SaltSize];
|
||||
Array.Copy(payload, 1, salt, 0, SaltSize);
|
||||
|
||||
var expectedHash = new byte[HashSize];
|
||||
Array.Copy(payload, 1 + SaltSize, expectedHash, 0, HashSize);
|
||||
|
||||
var actualHash = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, HashSize);
|
||||
|
||||
var success = CryptographicOperations.FixedTimeEquals(expectedHash, actualHash);
|
||||
if (!success)
|
||||
{
|
||||
return PasswordVerificationResult.Failed;
|
||||
}
|
||||
|
||||
return iterations < Iterations
|
||||
? PasswordVerificationResult.SuccessRehashNeeded
|
||||
: PasswordVerificationResult.Success;
|
||||
return PasswordVerificationResult.Failed;
|
||||
}
|
||||
|
||||
private static bool IsArgon2Hash(string value) =>
|
||||
value.StartsWith("$argon2id$", StringComparison.Ordinal);
|
||||
|
||||
private static bool IsLegacyPbkdf2Hash(string value) =>
|
||||
value.StartsWith("PBKDF2.", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard;
|
||||
|
||||
@@ -13,6 +14,8 @@ internal sealed class StandardPluginOptions
|
||||
|
||||
public TokenSigningOptions TokenSigning { get; set; } = new();
|
||||
|
||||
public PasswordHashOptions PasswordHashing { get; set; } = new();
|
||||
|
||||
public void Normalize(string configPath)
|
||||
{
|
||||
TokenSigning.Normalize(configPath);
|
||||
@@ -23,6 +26,7 @@ internal sealed class StandardPluginOptions
|
||||
BootstrapUser?.Validate(pluginName);
|
||||
PasswordPolicy.Validate(pluginName);
|
||||
Lockout.Validate(pluginName);
|
||||
PasswordHashing.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ using StellaOps.Authority.Plugin.Standard.Bootstrap;
|
||||
using StellaOps.Authority.Plugin.Standard.Security;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard;
|
||||
|
||||
@@ -25,10 +27,11 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
|
||||
|
||||
var pluginName = context.Plugin.Manifest.Name;
|
||||
|
||||
context.Services.TryAddSingleton<IPasswordHasher, Pbkdf2PasswordHasher>();
|
||||
context.Services.AddSingleton<StandardClaimsEnricher>();
|
||||
context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
|
||||
|
||||
context.Services.AddStellaOpsCrypto();
|
||||
|
||||
var configPath = context.Plugin.Manifest.ConfigPath;
|
||||
|
||||
context.Services.AddOptions<StandardPluginOptions>(pluginName)
|
||||
@@ -45,7 +48,8 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
|
||||
var pluginOptions = optionsMonitor.Get(pluginName);
|
||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
|
||||
var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider);
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
|
||||
return new StandardUserCredentialStore(
|
||||
@@ -59,7 +63,9 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
|
||||
context.Services.AddSingleton(sp =>
|
||||
{
|
||||
var clientStore = sp.GetRequiredService<IAuthorityClientStore>();
|
||||
return new StandardClientProvisioningStore(pluginName, clientStore);
|
||||
var revocationStore = sp.GetRequiredService<IAuthorityRevocationStore>();
|
||||
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||
return new StandardClientProvisioningStore(pluginName, clientStore, revocationStore, timeProvider);
|
||||
});
|
||||
|
||||
context.Services.AddSingleton<IIdentityProviderPlugin>(sp =>
|
||||
|
||||
@@ -18,5 +18,7 @@
|
||||
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
@@ -9,11 +10,19 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
|
||||
{
|
||||
private readonly string pluginName;
|
||||
private readonly IAuthorityClientStore clientStore;
|
||||
private readonly IAuthorityRevocationStore revocationStore;
|
||||
private readonly TimeProvider clock;
|
||||
|
||||
public StandardClientProvisioningStore(string pluginName, IAuthorityClientStore clientStore)
|
||||
public StandardClientProvisioningStore(
|
||||
string pluginName,
|
||||
IAuthorityClientStore clientStore,
|
||||
IAuthorityRevocationStore revocationStore,
|
||||
TimeProvider clock)
|
||||
{
|
||||
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
|
||||
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
|
||||
this.revocationStore = revocationStore ?? throw new ArgumentNullException(nameof(revocationStore));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityPluginOperationResult<AuthorityClientDescriptor>> CreateOrUpdateAsync(
|
||||
@@ -28,7 +37,7 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
|
||||
}
|
||||
|
||||
var document = await clientStore.FindByClientIdAsync(registration.ClientId, cancellationToken).ConfigureAwait(false)
|
||||
?? new AuthorityClientDocument { ClientId = registration.ClientId, CreatedAt = DateTimeOffset.UtcNow };
|
||||
?? new AuthorityClientDocument { ClientId = registration.ClientId, CreatedAt = clock.GetUtcNow() };
|
||||
|
||||
document.Plugin = pluginName;
|
||||
document.ClientType = registration.Confidential ? "confidential" : "public";
|
||||
@@ -36,6 +45,7 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
|
||||
document.SecretHash = registration.Confidential && registration.ClientSecret is not null
|
||||
? AuthoritySecretHasher.ComputeHash(registration.ClientSecret)
|
||||
: null;
|
||||
document.UpdatedAt = clock.GetUtcNow();
|
||||
|
||||
document.RedirectUris = registration.RedirectUris.Select(static uri => uri.ToString()).ToList();
|
||||
document.PostLogoutRedirectUris = registration.PostLogoutRedirectUris.Select(static uri => uri.ToString()).ToList();
|
||||
@@ -51,6 +61,7 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
|
||||
}
|
||||
|
||||
await clientStore.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
await revocationStore.RemoveAsync("client", registration.ClientId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Success(ToDescriptor(document));
|
||||
}
|
||||
@@ -64,9 +75,39 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
|
||||
public async ValueTask<AuthorityPluginOperationResult> DeleteAsync(string clientId, CancellationToken cancellationToken)
|
||||
{
|
||||
var deleted = await clientStore.DeleteByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
|
||||
return deleted
|
||||
? AuthorityPluginOperationResult.Success()
|
||||
: AuthorityPluginOperationResult.Failure("not_found", "Client was not found.");
|
||||
if (!deleted)
|
||||
{
|
||||
return AuthorityPluginOperationResult.Failure("not_found", "Client was not found.");
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["plugin"] = pluginName
|
||||
};
|
||||
|
||||
var revocation = new AuthorityRevocationDocument
|
||||
{
|
||||
Category = "client",
|
||||
RevocationId = clientId,
|
||||
ClientId = clientId,
|
||||
Reason = "operator_request",
|
||||
ReasonDescription = $"Client '{clientId}' deleted via plugin '{pluginName}'.",
|
||||
RevokedAt = now,
|
||||
EffectiveAt = now,
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await revocationStore.UpsertAsync(revocation, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Revocation export should proceed even if the metadata write fails.
|
||||
}
|
||||
|
||||
return AuthorityPluginOperationResult.Success();
|
||||
}
|
||||
|
||||
private static AuthorityClientDescriptor ToDescriptor(AuthorityClientDocument document)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -8,6 +9,7 @@ using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Standard.Security;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Storage;
|
||||
|
||||
@@ -43,9 +45,11 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
string password,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var auditProperties = new List<AuthEventProperty>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password))
|
||||
{
|
||||
return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials);
|
||||
return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials, auditProperties: auditProperties);
|
||||
}
|
||||
|
||||
var normalized = NormalizeUsername(username);
|
||||
@@ -56,17 +60,24 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
if (user is null)
|
||||
{
|
||||
logger.LogWarning("Plugin {PluginName} failed password verification for unknown user {Username}.", pluginName, normalized);
|
||||
return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials);
|
||||
return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials, auditProperties: auditProperties);
|
||||
}
|
||||
|
||||
if (options.Lockout.Enabled && user.Lockout.LockoutEnd is { } lockoutEnd && lockoutEnd > DateTimeOffset.UtcNow)
|
||||
{
|
||||
var retryAfter = lockoutEnd - DateTimeOffset.UtcNow;
|
||||
logger.LogWarning("Plugin {PluginName} denied access for {Username} due to lockout (retry after {RetryAfter}).", pluginName, normalized, retryAfter);
|
||||
auditProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "plugin.lockout_until",
|
||||
Value = ClassifiedString.Public(lockoutEnd.ToString("O", CultureInfo.InvariantCulture))
|
||||
});
|
||||
|
||||
return AuthorityCredentialVerificationResult.Failure(
|
||||
AuthorityCredentialFailureCode.LockedOut,
|
||||
"Account is temporarily locked.",
|
||||
retryAfter);
|
||||
retryAfter,
|
||||
auditProperties);
|
||||
}
|
||||
|
||||
var verification = passwordHasher.Verify(password, user.PasswordHash);
|
||||
@@ -75,8 +86,14 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
if (verification == PasswordVerificationResult.SuccessRehashNeeded)
|
||||
{
|
||||
user.PasswordHash = passwordHasher.Hash(password);
|
||||
auditProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "plugin.rehashed",
|
||||
Value = ClassifiedString.Public("argon2id")
|
||||
});
|
||||
}
|
||||
|
||||
var previousFailures = user.Lockout.FailedAttempts;
|
||||
ResetLockout(user);
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await users.ReplaceOneAsync(
|
||||
@@ -84,8 +101,20 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
user,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (previousFailures > 0)
|
||||
{
|
||||
auditProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "plugin.failed_attempts_cleared",
|
||||
Value = ClassifiedString.Public(previousFailures.ToString(CultureInfo.InvariantCulture))
|
||||
});
|
||||
}
|
||||
|
||||
var descriptor = ToDescriptor(user);
|
||||
return AuthorityCredentialVerificationResult.Success(descriptor, descriptor.RequiresPasswordReset ? "Password reset required." : null);
|
||||
return AuthorityCredentialVerificationResult.Success(
|
||||
descriptor,
|
||||
descriptor.RequiresPasswordReset ? "Password reset required." : null,
|
||||
auditProperties);
|
||||
}
|
||||
|
||||
await RegisterFailureAsync(user, cancellationToken).ConfigureAwait(false);
|
||||
@@ -98,10 +127,26 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
|
||||
? lockoutTime - DateTimeOffset.UtcNow
|
||||
: null;
|
||||
|
||||
auditProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "plugin.failed_attempts",
|
||||
Value = ClassifiedString.Public(user.Lockout.FailedAttempts.ToString(CultureInfo.InvariantCulture))
|
||||
});
|
||||
|
||||
if (user.Lockout.LockoutEnd is { } pendingLockout)
|
||||
{
|
||||
auditProperties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "plugin.lockout_until",
|
||||
Value = ClassifiedString.Public(pendingLockout.ToString("O", CultureInfo.InvariantCulture))
|
||||
});
|
||||
}
|
||||
|
||||
return AuthorityCredentialVerificationResult.Failure(
|
||||
code,
|
||||
code == AuthorityCredentialFailureCode.LockedOut ? "Account is temporarily locked." : "Invalid credentials.",
|
||||
retry);
|
||||
retry,
|
||||
auditProperties);
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| PLG6.DOC | DONE (2025-10-11) | BE-Auth Plugin, Docs Guild | PLG1–PLG5 | Final polish + diagrams for plugin developer guide. | Docs team delivers copy-edit + exported diagrams; PR merged. |
|
||||
| SEC1.PLG | TODO | 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 | TODO | 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. |
|
||||
| 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. |
|
||||
| SEC4.PLG | TODO | 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. |
|
||||
| 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. |
|
||||
| PLG4-6.CAPABILITIES | DOING (2025-10-10) | 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. |
|
||||
| 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. |
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.Plugins.Abstractions.Tests;
|
||||
|
||||
@@ -10,12 +11,18 @@ public class AuthorityCredentialVerificationResultTests
|
||||
{
|
||||
var user = new AuthorityUserDescriptor("subject-1", "user", "User", false);
|
||||
|
||||
var result = AuthorityCredentialVerificationResult.Success(user, "ok");
|
||||
var auditProperties = new[]
|
||||
{
|
||||
new AuthEventProperty { Name = "test", Value = ClassifiedString.Public("value") }
|
||||
};
|
||||
|
||||
var result = AuthorityCredentialVerificationResult.Success(user, "ok", auditProperties);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal(user, result.User);
|
||||
Assert.Null(result.FailureCode);
|
||||
Assert.Equal("ok", result.Message);
|
||||
Assert.Collection(result.AuditProperties, property => Assert.Equal("test", property.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -27,12 +34,18 @@ public class AuthorityCredentialVerificationResultTests
|
||||
[Fact]
|
||||
public void Failure_SetsFailureCode()
|
||||
{
|
||||
var result = AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.LockedOut, "locked", TimeSpan.FromMinutes(5));
|
||||
var auditProperties = new[]
|
||||
{
|
||||
new AuthEventProperty { Name = "reason", Value = ClassifiedString.Public("lockout") }
|
||||
};
|
||||
|
||||
var result = AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.LockedOut, "locked", TimeSpan.FromMinutes(5), auditProperties);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Null(result.User);
|
||||
Assert.Equal(AuthorityCredentialFailureCode.LockedOut, result.FailureCode);
|
||||
Assert.Equal("locked", result.Message);
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), result.RetryAfter);
|
||||
Assert.Collection(result.AuditProperties, property => Assert.Equal("reason", property.Name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.Plugins.Abstractions;
|
||||
|
||||
@@ -311,13 +312,15 @@ public sealed record AuthorityCredentialVerificationResult
|
||||
AuthorityUserDescriptor? user,
|
||||
AuthorityCredentialFailureCode? failureCode,
|
||||
string? message,
|
||||
TimeSpan? retryAfter)
|
||||
TimeSpan? retryAfter,
|
||||
IReadOnlyList<AuthEventProperty> auditProperties)
|
||||
{
|
||||
Succeeded = succeeded;
|
||||
User = user;
|
||||
FailureCode = failureCode;
|
||||
Message = message;
|
||||
RetryAfter = retryAfter;
|
||||
AuditProperties = auditProperties ?? Array.Empty<AuthEventProperty>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -345,13 +348,19 @@ public sealed record AuthorityCredentialVerificationResult
|
||||
/// </summary>
|
||||
public TimeSpan? RetryAfter { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional audit properties emitted by the credential store.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AuthEventProperty> AuditProperties { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Builds a successful verification result.
|
||||
/// </summary>
|
||||
public static AuthorityCredentialVerificationResult Success(
|
||||
AuthorityUserDescriptor user,
|
||||
string? message = null)
|
||||
=> new(true, user ?? throw new ArgumentNullException(nameof(user)), null, message, null);
|
||||
string? message = null,
|
||||
IReadOnlyList<AuthEventProperty>? auditProperties = null)
|
||||
=> new(true, user ?? throw new ArgumentNullException(nameof(user)), null, message, null, auditProperties ?? Array.Empty<AuthEventProperty>());
|
||||
|
||||
/// <summary>
|
||||
/// Builds a failed verification result.
|
||||
@@ -359,8 +368,9 @@ public sealed record AuthorityCredentialVerificationResult
|
||||
public static AuthorityCredentialVerificationResult Failure(
|
||||
AuthorityCredentialFailureCode failureCode,
|
||||
string? message = null,
|
||||
TimeSpan? retryAfter = null)
|
||||
=> new(false, null, failureCode, message, retryAfter);
|
||||
TimeSpan? retryAfter = null,
|
||||
IReadOnlyList<AuthEventProperty>? auditProperties = null)
|
||||
=> new(false, null, failureCode, message, retryAfter, auditProperties ?? Array.Empty<AuthEventProperty>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -11,4 +11,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -20,5 +20,7 @@ public static class AuthorityMongoDefaults
|
||||
public const string Scopes = "authority_scopes";
|
||||
public const string Tokens = "authority_tokens";
|
||||
public const string LoginAttempts = "authority_login_attempts";
|
||||
public const string Revocations = "authority_revocations";
|
||||
public const string RevocationState = "authority_revocation_state";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,16 @@ public sealed class AuthorityLoginAttemptDocument
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
|
||||
|
||||
[BsonElement("eventType")]
|
||||
public string EventType { get; set; } = "authority.unknown";
|
||||
|
||||
[BsonElement("outcome")]
|
||||
public string Outcome { get; set; } = "unknown";
|
||||
|
||||
[BsonElement("correlationId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? CorrelationId { get; set; }
|
||||
|
||||
[BsonElement("subjectId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SubjectId { get; set; }
|
||||
@@ -32,6 +42,9 @@ public sealed class AuthorityLoginAttemptDocument
|
||||
[BsonElement("successful")]
|
||||
public bool Successful { get; set; }
|
||||
|
||||
[BsonElement("scopes")]
|
||||
public List<string> Scopes { get; set; } = new();
|
||||
|
||||
[BsonElement("reason")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Reason { get; set; }
|
||||
@@ -40,6 +53,26 @@ public sealed class AuthorityLoginAttemptDocument
|
||||
[BsonIgnoreIfNull]
|
||||
public string? RemoteAddress { get; set; }
|
||||
|
||||
[BsonElement("properties")]
|
||||
public List<AuthorityLoginAttemptPropertyDocument> Properties { get; set; } = new();
|
||||
|
||||
[BsonElement("occurredAt")]
|
||||
public DateTimeOffset OccurredAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an additional classified property captured for an authority login attempt.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AuthorityLoginAttemptPropertyDocument
|
||||
{
|
||||
[BsonElement("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("value")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Value { get; set; }
|
||||
|
||||
[BsonElement("classification")]
|
||||
public string Classification { get; set; } = "none";
|
||||
}
|
||||
|
||||
@@ -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 revocation entry emitted by Authority (subject/client/token/key).
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AuthorityRevocationDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
|
||||
|
||||
[BsonElement("category")]
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("revocationId")]
|
||||
public string RevocationId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("tokenType")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? TokenType { get; set; }
|
||||
|
||||
[BsonElement("subjectId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SubjectId { get; set; }
|
||||
|
||||
[BsonElement("clientId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
[BsonElement("reason")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Reason { get; set; }
|
||||
|
||||
[BsonElement("reasonDescription")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ReasonDescription { get; set; }
|
||||
|
||||
[BsonElement("revokedAt")]
|
||||
public DateTimeOffset RevokedAt { get; set; }
|
||||
|
||||
[BsonElement("effectiveAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? EffectiveAt { get; set; }
|
||||
|
||||
[BsonElement("expiresAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
[BsonElement("scopes")]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<string>? Scopes { get; set; }
|
||||
|
||||
[BsonElement("fingerprint")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Fingerprint { get; set; }
|
||||
|
||||
[BsonElement("metadata")]
|
||||
[BsonIgnoreIfNull]
|
||||
public Dictionary<string, string?>? Metadata { get; set; }
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[BsonElement("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class AuthorityRevocationExportStateDocument
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = "state";
|
||||
|
||||
[BsonElement("sequence")]
|
||||
public long Sequence { get; set; }
|
||||
|
||||
[BsonElement("lastBundleId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? LastBundleId { get; set; }
|
||||
|
||||
[BsonElement("lastIssuedAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? LastIssuedAt { get; set; }
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
@@ -51,4 +52,16 @@ public sealed class AuthorityTokenDocument
|
||||
[BsonElement("revokedAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
|
||||
[BsonElement("revokedReason")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? RevokedReason { get; set; }
|
||||
|
||||
[BsonElement("revokedReasonDescription")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? RevokedReasonDescription { get; set; }
|
||||
|
||||
[BsonElement("revokedMetadata")]
|
||||
[BsonIgnoreIfNull]
|
||||
public Dictionary<string, string?>? RevokedMetadata { get; set; }
|
||||
}
|
||||
|
||||
@@ -86,17 +86,32 @@ public static class ServiceCollectionExtensions
|
||||
return database.GetCollection<AuthorityLoginAttemptDocument>(AuthorityMongoDefaults.Collections.LoginAttempts);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityRevocationDocument>(AuthorityMongoDefaults.Collections.Revocations);
|
||||
});
|
||||
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<AuthorityRevocationExportStateDocument>(AuthorityMongoDefaults.Collections.RevocationState);
|
||||
});
|
||||
|
||||
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<IAuthorityUserStore, AuthorityUserStore>();
|
||||
services.TryAddSingleton<IAuthorityClientStore, AuthorityClientStore>();
|
||||
services.TryAddSingleton<IAuthorityScopeStore, AuthorityScopeStore>();
|
||||
services.TryAddSingleton<IAuthorityTokenStore, AuthorityTokenStore>();
|
||||
services.TryAddSingleton<IAuthorityLoginAttemptStore, AuthorityLoginAttemptStore>();
|
||||
services.TryAddSingleton<IAuthorityRevocationStore, AuthorityRevocationStore>();
|
||||
services.TryAddSingleton<IAuthorityRevocationExportStateStore, AuthorityRevocationExportStateStore>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,11 @@ internal sealed class AuthorityLoginAttemptCollectionInitializer : IAuthorityCol
|
||||
new CreateIndexOptions { Name = "login_attempt_subject_time" }),
|
||||
new CreateIndexModel<AuthorityLoginAttemptDocument>(
|
||||
Builders<AuthorityLoginAttemptDocument>.IndexKeys.Descending(a => a.OccurredAt),
|
||||
new CreateIndexOptions { Name = "login_attempt_time" })
|
||||
new CreateIndexOptions { Name = "login_attempt_time" }),
|
||||
new CreateIndexModel<AuthorityLoginAttemptDocument>(
|
||||
Builders<AuthorityLoginAttemptDocument>.IndexKeys
|
||||
.Ascending(a => a.CorrelationId),
|
||||
new CreateIndexOptions { Name = "login_attempt_correlation", Sparse = true })
|
||||
};
|
||||
|
||||
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
|
||||
internal sealed class AuthorityRevocationCollectionInitializer : IAuthorityCollectionInitializer
|
||||
{
|
||||
public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
|
||||
var collection = database.GetCollection<AuthorityRevocationDocument>(AuthorityMongoDefaults.Collections.Revocations);
|
||||
var indexModels = new List<CreateIndexModel<AuthorityRevocationDocument>>
|
||||
{
|
||||
new(
|
||||
Builders<AuthorityRevocationDocument>.IndexKeys
|
||||
.Ascending(d => d.Category)
|
||||
.Ascending(d => d.RevocationId),
|
||||
new CreateIndexOptions<AuthorityRevocationDocument> { Name = "revocation_identity_unique", Unique = true }),
|
||||
new(
|
||||
Builders<AuthorityRevocationDocument>.IndexKeys.Ascending(d => d.RevokedAt),
|
||||
new CreateIndexOptions<AuthorityRevocationDocument> { Name = "revocation_revokedAt" }),
|
||||
new(
|
||||
Builders<AuthorityRevocationDocument>.IndexKeys.Ascending(d => d.ExpiresAt),
|
||||
new CreateIndexOptions<AuthorityRevocationDocument> { Name = "revocation_expiresAt" })
|
||||
};
|
||||
|
||||
await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,12 @@ internal sealed class AuthorityTokenCollectionInitializer : IAuthorityCollection
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_subject" }),
|
||||
new(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ClientId),
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_client" })
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_client" }),
|
||||
new(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys
|
||||
.Ascending(t => t.Status)
|
||||
.Ascending(t => t.RevokedAt),
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_status_revokedAt" })
|
||||
};
|
||||
|
||||
var expirationFilter = Builders<AuthorityTokenDocument>.Filter.Exists(t => t.ExpiresAt, true);
|
||||
|
||||
@@ -23,9 +23,10 @@ internal sealed class AuthorityLoginAttemptStore : IAuthorityLoginAttemptStore
|
||||
|
||||
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
logger.LogDebug(
|
||||
"Recorded login attempt for subject '{SubjectId}' (success={Successful}).",
|
||||
"Recorded authority audit event {EventType} for subject '{SubjectId}' with outcome {Outcome}.",
|
||||
document.EventType,
|
||||
document.SubjectId ?? document.Username ?? "<unknown>",
|
||||
document.Successful);
|
||||
document.Outcome);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal sealed class AuthorityRevocationExportStateStore : IAuthorityRevocationExportStateStore
|
||||
{
|
||||
private const string StateId = "state";
|
||||
|
||||
private readonly IMongoCollection<AuthorityRevocationExportStateDocument> collection;
|
||||
private readonly ILogger<AuthorityRevocationExportStateStore> logger;
|
||||
|
||||
public AuthorityRevocationExportStateStore(
|
||||
IMongoCollection<AuthorityRevocationExportStateDocument> collection,
|
||||
ILogger<AuthorityRevocationExportStateStore> logger)
|
||||
{
|
||||
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Id, StateId);
|
||||
return await collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityRevocationExportStateDocument> UpdateAsync(
|
||||
long expectedSequence,
|
||||
long newSequence,
|
||||
string bundleId,
|
||||
DateTimeOffset issuedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (newSequence <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(newSequence), "Sequence must be positive.");
|
||||
}
|
||||
|
||||
var filter = Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Id, StateId);
|
||||
|
||||
if (expectedSequence > 0)
|
||||
{
|
||||
filter &= Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Sequence, expectedSequence);
|
||||
}
|
||||
else
|
||||
{
|
||||
filter &= Builders<AuthorityRevocationExportStateDocument>.Filter.Or(
|
||||
Builders<AuthorityRevocationExportStateDocument>.Filter.Exists(d => d.Sequence, false),
|
||||
Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Sequence, 0));
|
||||
}
|
||||
|
||||
var update = Builders<AuthorityRevocationExportStateDocument>.Update
|
||||
.Set(d => d.Sequence, newSequence)
|
||||
.Set(d => d.LastBundleId, bundleId)
|
||||
.Set(d => d.LastIssuedAt, issuedAt);
|
||||
|
||||
var options = new FindOneAndUpdateOptions<AuthorityRevocationExportStateDocument>
|
||||
{
|
||||
IsUpsert = expectedSequence == 0,
|
||||
ReturnDocument = ReturnDocument.After
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var result = await collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken).ConfigureAwait(false);
|
||||
if (result is null)
|
||||
{
|
||||
throw new InvalidOperationException("Revocation export state update conflict.");
|
||||
}
|
||||
|
||||
logger.LogDebug("Updated revocation export state to sequence {Sequence}.", result.Sequence);
|
||||
return result;
|
||||
}
|
||||
catch (MongoCommandException ex) when (string.Equals(ex.CodeName, "DuplicateKey", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Revocation export state update conflict due to concurrent writer.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal sealed class AuthorityRevocationStore : IAuthorityRevocationStore
|
||||
{
|
||||
private readonly IMongoCollection<AuthorityRevocationDocument> collection;
|
||||
private readonly ILogger<AuthorityRevocationStore> logger;
|
||||
|
||||
public AuthorityRevocationStore(
|
||||
IMongoCollection<AuthorityRevocationDocument> collection,
|
||||
ILogger<AuthorityRevocationStore> logger)
|
||||
{
|
||||
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(document.Category))
|
||||
{
|
||||
throw new ArgumentException("Revocation category is required.", nameof(document));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(document.RevocationId))
|
||||
{
|
||||
throw new ArgumentException("Revocation identifier is required.", nameof(document));
|
||||
}
|
||||
|
||||
document.Category = document.Category.Trim();
|
||||
document.RevocationId = document.RevocationId.Trim();
|
||||
document.Scopes = NormalizeScopes(document.Scopes);
|
||||
document.Metadata = NormalizeMetadata(document.Metadata);
|
||||
|
||||
var filter = Builders<AuthorityRevocationDocument>.Filter.And(
|
||||
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.Category, document.Category),
|
||||
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.RevocationId, document.RevocationId));
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
document.UpdatedAt = now;
|
||||
|
||||
var existing = await collection
|
||||
.Find(filter)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
document.CreatedAt = now;
|
||||
}
|
||||
else
|
||||
{
|
||||
document.Id = existing.Id;
|
||||
document.CreatedAt = existing.CreatedAt;
|
||||
}
|
||||
|
||||
await collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
logger.LogDebug("Upserted Authority revocation entry {Category}:{RevocationId}.", document.Category, document.RevocationId);
|
||||
}
|
||||
|
||||
public async ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(revocationId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var filter = Builders<AuthorityRevocationDocument>.Filter.And(
|
||||
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.Category, category.Trim()),
|
||||
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.RevocationId, revocationId.Trim()));
|
||||
|
||||
var result = await collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
|
||||
if (result.DeletedCount > 0)
|
||||
{
|
||||
logger.LogInformation("Removed Authority revocation entry {Category}:{RevocationId}.", category, revocationId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<AuthorityRevocationDocument>.Filter.Or(
|
||||
Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.ExpiresAt, null),
|
||||
Builders<AuthorityRevocationDocument>.Filter.Gt(d => d.ExpiresAt, asOf));
|
||||
|
||||
var documents = await collection
|
||||
.Find(filter)
|
||||
.Sort(Builders<AuthorityRevocationDocument>.Sort.Ascending(d => d.Category).Ascending(d => d.RevocationId))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return documents;
|
||||
}
|
||||
|
||||
private static List<string>? NormalizeScopes(List<string>? scopes)
|
||||
{
|
||||
if (scopes is null || scopes.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var distinct = scopes
|
||||
.Where(scope => !string.IsNullOrWhiteSpace(scope))
|
||||
.Select(scope => scope.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(scope => scope, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return distinct.Count == 0 ? null : distinct;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string?>? NormalizeMetadata(Dictionary<string, string?>? metadata)
|
||||
{
|
||||
if (metadata is null || metadata.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new SortedDictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in metadata)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result[pair.Key.Trim()] = pair.Value;
|
||||
}
|
||||
|
||||
return result.Count == 0 ? null : new Dictionary<string, string?>(result, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
@@ -51,7 +52,14 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, CancellationToken cancellationToken)
|
||||
public async ValueTask UpdateStatusAsync(
|
||||
string tokenId,
|
||||
string status,
|
||||
DateTimeOffset? revokedAt,
|
||||
string? reason,
|
||||
string? reasonDescription,
|
||||
IReadOnlyDictionary<string, string?>? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tokenId))
|
||||
{
|
||||
@@ -65,7 +73,10 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
|
||||
|
||||
var update = Builders<AuthorityTokenDocument>.Update
|
||||
.Set(t => t.Status, status)
|
||||
.Set(t => t.RevokedAt, revokedAt);
|
||||
.Set(t => t.RevokedAt, revokedAt)
|
||||
.Set(t => t.RevokedReason, reason)
|
||||
.Set(t => t.RevokedReasonDescription, reasonDescription)
|
||||
.Set(t => t.RevokedMetadata, metadata is null ? null : new Dictionary<string, string?>(metadata, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
var result = await collection.UpdateOneAsync(
|
||||
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, tokenId.Trim()),
|
||||
@@ -90,4 +101,24 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
|
||||
|
||||
return result.DeletedCount;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Status, "revoked");
|
||||
|
||||
if (issuedAfter is DateTimeOffset threshold)
|
||||
{
|
||||
filter = Builders<AuthorityTokenDocument>.Filter.And(
|
||||
filter,
|
||||
Builders<AuthorityTokenDocument>.Filter.Gt(t => t.RevokedAt, threshold));
|
||||
}
|
||||
|
||||
var documents = await collection
|
||||
.Find(filter)
|
||||
.Sort(Builders<AuthorityTokenDocument>.Sort.Ascending(t => t.RevokedAt).Ascending(t => t.TokenId))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return documents;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityRevocationExportStateStore
|
||||
{
|
||||
ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<AuthorityRevocationExportStateDocument> UpdateAsync(
|
||||
long expectedSequence,
|
||||
long newSequence,
|
||||
string bundleId,
|
||||
DateTimeOffset issuedAt,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
public interface IAuthorityRevocationStore
|
||||
{
|
||||
ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -10,7 +10,16 @@ public interface IAuthorityTokenStore
|
||||
|
||||
ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, CancellationToken cancellationToken);
|
||||
ValueTask UpdateStatusAsync(
|
||||
string tokenId,
|
||||
string status,
|
||||
DateTimeOffset? revokedAt,
|
||||
string? reason,
|
||||
string? reasonDescription,
|
||||
IReadOnlyDictionary<string, string?>? metadata,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
@@ -12,6 +14,8 @@ using StellaOps.Authority.OpenIddict.Handlers;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
using static StellaOps.Authority.Tests.OpenIddict.TestHelpers;
|
||||
|
||||
@@ -30,7 +34,14 @@ public class ClientCredentialsHandlersTests
|
||||
allowedScopes: "jobs:read");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var handler = new ValidateClientCredentialsHandler(new TestClientStore(clientDocument), registry, TestActivitySource, NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:write");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
@@ -51,7 +62,14 @@ public class ClientCredentialsHandlersTests
|
||||
allowedScopes: "jobs:read jobs:trigger");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var handler = new ValidateClientCredentialsHandler(new TestClientStore(clientDocument), registry, TestActivitySource, NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
TimeProvider.System,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
@@ -78,7 +96,17 @@ public class ClientCredentialsHandlersTests
|
||||
var descriptor = CreateDescriptor(clientDocument);
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: descriptor);
|
||||
var tokenStore = new TestTokenStore();
|
||||
var handler = new HandleClientCredentialsHandler(registry, tokenStore, TimeProvider.System, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance);
|
||||
var authSink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
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);
|
||||
@@ -93,12 +121,23 @@ public class ClientCredentialsHandlersTests
|
||||
Assert.True(context.IsRequestHandled);
|
||||
Assert.NotNull(context.Principal);
|
||||
|
||||
Assert.Contains(authSink.Events, record => record.EventType == "authority.client_credentials.grant" && record.Outcome == AuthEventOutcome.Success);
|
||||
|
||||
var identityProviderClaim = context.Principal?.GetClaim(StellaOpsClaimTypes.IdentityProvider);
|
||||
Assert.Equal(clientDocument.Plugin, identityProviderClaim);
|
||||
|
||||
var tokenId = context.Principal?.GetClaim(OpenIddictConstants.Claims.JwtId);
|
||||
var principal = context.Principal ?? throw new InvalidOperationException("Principal missing");
|
||||
var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId);
|
||||
Assert.False(string.IsNullOrWhiteSpace(tokenId));
|
||||
|
||||
var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction)
|
||||
{
|
||||
Principal = principal,
|
||||
AccessTokenPrincipal = principal
|
||||
};
|
||||
|
||||
await persistHandler.HandleAsync(signInContext);
|
||||
|
||||
var persisted = Assert.IsType<AuthorityTokenDocument>(tokenStore.Inserted);
|
||||
Assert.Equal(tokenId, persisted.TokenId);
|
||||
Assert.Equal(clientDocument.ClientId, persisted.ClientId);
|
||||
@@ -236,11 +275,14 @@ internal sealed class TestTokenStore : IAuthorityTokenStore
|
||||
public ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<AuthorityTokenDocument?>(null);
|
||||
|
||||
public ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, CancellationToken cancellationToken)
|
||||
public ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, string? reason, string? reasonDescription, IReadOnlyDictionary<string, string?>? metadata, CancellationToken cancellationToken)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(0L);
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<IReadOnlyList<AuthorityTokenDocument>>(Array.Empty<AuthorityTokenDocument>());
|
||||
}
|
||||
|
||||
internal sealed class TestClaimsEnricher : IClaimsEnricher
|
||||
@@ -328,6 +370,30 @@ internal sealed class TestIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
=> ValueTask.FromResult(AuthorityPluginHealthResult.Healthy());
|
||||
}
|
||||
|
||||
internal sealed class TestAuthEventSink : IAuthEventSink
|
||||
{
|
||||
public List<AuthEventRecord> Events { get; } = new();
|
||||
|
||||
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
Events.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 = clientId;
|
||||
|
||||
public void SetSubjectId(string? subjectId) => metadata.SubjectId = subjectId;
|
||||
|
||||
public void SetTag(string name, string? value) => metadata.SetTag(name, value);
|
||||
}
|
||||
|
||||
internal static class TestHelpers
|
||||
{
|
||||
public static AuthorityClientDocument CreateClient(
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
using StellaOps.Authority.OpenIddict;
|
||||
using StellaOps.Authority.OpenIddict.Handlers;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.OpenIddict;
|
||||
|
||||
public class PasswordGrantHandlersTests
|
||||
{
|
||||
private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests");
|
||||
|
||||
[Fact]
|
||||
public async Task HandlePasswordGrant_EmitsSuccessAuditEvent()
|
||||
{
|
||||
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 handle = new HandlePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance);
|
||||
|
||||
var transaction = CreatePasswordTransaction("alice", "Password1!");
|
||||
|
||||
await validate.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction));
|
||||
await handle.HandleAsync(new OpenIddictServerEvents.HandleTokenRequestContext(transaction));
|
||||
|
||||
Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandlePasswordGrant_EmitsFailureAuditEvent()
|
||||
{
|
||||
var sink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var registry = CreateRegistry(new FailureCredentialStore());
|
||||
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
|
||||
var handle = new HandlePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance);
|
||||
|
||||
var transaction = CreatePasswordTransaction("alice", "BadPassword!");
|
||||
|
||||
await validate.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction));
|
||||
await handle.HandleAsync(new OpenIddictServerEvents.HandleTokenRequestContext(transaction));
|
||||
|
||||
Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandlePasswordGrant_EmitsLockoutAuditEvent()
|
||||
{
|
||||
var sink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var registry = CreateRegistry(new LockoutCredentialStore());
|
||||
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
|
||||
var handle = new HandlePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance);
|
||||
|
||||
var transaction = CreatePasswordTransaction("alice", "Locked!");
|
||||
|
||||
await validate.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction));
|
||||
await handle.HandleAsync(new OpenIddictServerEvents.HandleTokenRequestContext(transaction));
|
||||
|
||||
Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.LockedOut);
|
||||
}
|
||||
|
||||
private static AuthorityIdentityProviderRegistry CreateRegistry(IUserCredentialStore store)
|
||||
{
|
||||
var plugin = new StubIdentityProviderPlugin("stub", store);
|
||||
return new AuthorityIdentityProviderRegistry(new[] { plugin }, NullLogger<AuthorityIdentityProviderRegistry>.Instance);
|
||||
}
|
||||
|
||||
private static OpenIddictServerTransaction CreatePasswordTransaction(string username, string password)
|
||||
{
|
||||
var request = new OpenIddictRequest
|
||||
{
|
||||
GrantType = OpenIddictConstants.GrantTypes.Password,
|
||||
Username = username,
|
||||
Password = password
|
||||
};
|
||||
|
||||
return new OpenIddictServerTransaction
|
||||
{
|
||||
EndpointType = OpenIddictServerEndpointType.Token,
|
||||
Options = new OpenIddictServerOptions(),
|
||||
Request = request
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StubIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
{
|
||||
public StubIdentityProviderPlugin(string name, IUserCredentialStore store)
|
||||
{
|
||||
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");
|
||||
Context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build());
|
||||
Credentials = store;
|
||||
ClaimsEnricher = new NoopClaimsEnricher();
|
||||
Capabilities = new AuthorityIdentityProviderCapabilities(SupportsPassword: true, SupportsMfa: false, SupportsClientProvisioning: false);
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public string Type { get; }
|
||||
public AuthorityPluginContext Context { get; }
|
||||
public IUserCredentialStore Credentials { get; }
|
||||
public IClaimsEnricher ClaimsEnricher { get; }
|
||||
public IClientProvisioningStore? ClientProvisioning => null;
|
||||
public AuthorityIdentityProviderCapabilities Capabilities { get; }
|
||||
|
||||
public ValueTask<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(AuthorityPluginHealthResult.Healthy());
|
||||
}
|
||||
|
||||
private sealed class NoopClaimsEnricher : IClaimsEnricher
|
||||
{
|
||||
public ValueTask EnrichAsync(ClaimsIdentity identity, AuthorityClaimsEnrichmentContext context, CancellationToken cancellationToken)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class SuccessCredentialStore : IUserCredentialStore
|
||||
{
|
||||
public ValueTask<AuthorityCredentialVerificationResult> VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken)
|
||||
{
|
||||
var descriptor = new AuthorityUserDescriptor("subject", username, "User", requiresPasswordReset: false);
|
||||
return ValueTask.FromResult(AuthorityCredentialVerificationResult.Success(descriptor));
|
||||
}
|
||||
|
||||
public ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<AuthorityUserDescriptor?>(null);
|
||||
}
|
||||
|
||||
private sealed class FailureCredentialStore : IUserCredentialStore
|
||||
{
|
||||
public ValueTask<AuthorityCredentialVerificationResult> VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials, "Invalid username or password."));
|
||||
|
||||
public ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<AuthorityUserDescriptor?>(null);
|
||||
}
|
||||
|
||||
private sealed class LockoutCredentialStore : IUserCredentialStore
|
||||
{
|
||||
public ValueTask<AuthorityCredentialVerificationResult> VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken)
|
||||
{
|
||||
var retry = TimeSpan.FromMinutes(5);
|
||||
var properties = new[]
|
||||
{
|
||||
new AuthEventProperty
|
||||
{
|
||||
Name = "plugin.lockout_until",
|
||||
Value = ClassifiedString.Public(timeProvider.GetUtcNow().Add(retry).ToString("O", CultureInfo.InvariantCulture))
|
||||
}
|
||||
};
|
||||
|
||||
return ValueTask.FromResult(AuthorityCredentialVerificationResult.Failure(
|
||||
AuthorityCredentialFailureCode.LockedOut,
|
||||
"Account locked.",
|
||||
retryAfter: retry,
|
||||
auditProperties: properties));
|
||||
}
|
||||
|
||||
private static readonly TimeProvider timeProvider = TimeProvider.System;
|
||||
|
||||
public ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<AuthorityUserDescriptor?>(null);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,6 +17,8 @@ using StellaOps.Authority.Storage.Mongo.Extensions;
|
||||
using StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Feedser.Testing;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.OpenIddict;
|
||||
@@ -55,7 +57,10 @@ public sealed class TokenPersistenceIntegrationTests
|
||||
clientDescriptor: TestHelpers.CreateDescriptor(clientDocument));
|
||||
|
||||
var validateHandler = new ValidateClientCredentialsHandler(clientStore, registry, TestActivitySource, NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
var handleHandler = new HandleClientCredentialsHandler(registry, tokenStore, clock, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance);
|
||||
var authSink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var handleHandler = new HandleClientCredentialsHandler(registry, TestActivitySource, authSink, metadataAccessor, clock, NullLogger<HandleClientCredentialsHandler>.Instance);
|
||||
var persistHandler = new PersistTokensHandler(tokenStore, clock, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);
|
||||
|
||||
var transaction = TestHelpers.CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:trigger");
|
||||
transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(15);
|
||||
@@ -72,6 +77,14 @@ public sealed class TokenPersistenceIntegrationTests
|
||||
var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId);
|
||||
Assert.False(string.IsNullOrWhiteSpace(tokenId));
|
||||
|
||||
var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction)
|
||||
{
|
||||
Principal = principal,
|
||||
AccessTokenPrincipal = principal
|
||||
};
|
||||
|
||||
await persistHandler.HandleAsync(signInContext);
|
||||
|
||||
var stored = await tokenStore.FindByTokenIdAsync(tokenId!, CancellationToken.None);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(clientDocument.ClientId, stored!.ClientId);
|
||||
@@ -133,7 +146,7 @@ public sealed class TokenPersistenceIntegrationTests
|
||||
await tokenStore.InsertAsync(refreshToken, CancellationToken.None);
|
||||
|
||||
var revokedAt = now.AddMinutes(1);
|
||||
await tokenStore.UpdateStatusAsync(revokedTokenId, "revoked", revokedAt, CancellationToken.None);
|
||||
await tokenStore.UpdateStatusAsync(revokedTokenId, "revoked", revokedAt, "manual", null, null, CancellationToken.None);
|
||||
|
||||
var handler = new ValidateAccessTokenHandler(
|
||||
tokenStore,
|
||||
@@ -173,7 +186,8 @@ public sealed class TokenPersistenceIntegrationTests
|
||||
var stored = await tokenStore.FindByTokenIdAsync(revokedTokenId, CancellationToken.None);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal("revoked", stored!.Status);
|
||||
Assert.Equal(revokedAt, stored.RevokedAt);
|
||||
Assert.Equal(revokedAt, stored.RevokedAt);
|
||||
Assert.Equal("manual", stored.RevokedReason);
|
||||
}
|
||||
|
||||
private async Task ResetCollectionsAsync()
|
||||
@@ -206,3 +220,27 @@ 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Signing;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Signing;
|
||||
|
||||
public sealed class AuthoritySigningKeyManagerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Rotate_ReplacesActiveKeyAndRetiresPreviousKey()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("authority-signing-tests").FullName;
|
||||
var key1Relative = "key-1.pem";
|
||||
var key2Relative = "key-2.pem";
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(Path.Combine(tempDir, key1Relative));
|
||||
|
||||
var options = new StellaOpsAuthorityOptions
|
||||
{
|
||||
Issuer = new Uri("https://authority.test"),
|
||||
Storage = { ConnectionString = "mongodb://localhost/test" },
|
||||
Signing =
|
||||
{
|
||||
Enabled = true,
|
||||
ActiveKeyId = "key-1",
|
||||
KeyPath = key1Relative,
|
||||
Algorithm = SignatureAlgorithms.Es256,
|
||||
KeySource = "file",
|
||||
Provider = "default"
|
||||
}
|
||||
};
|
||||
|
||||
using var provider = BuildProvider(tempDir, options);
|
||||
var manager = provider.GetRequiredService<AuthoritySigningKeyManager>();
|
||||
var jwksService = provider.GetRequiredService<AuthorityJwksService>();
|
||||
|
||||
var initial = jwksService.Build();
|
||||
var initialKey = Assert.Single(initial.Keys);
|
||||
Assert.Equal("key-1", initialKey.Kid);
|
||||
Assert.Equal(AuthoritySigningKeyStatus.Active, initialKey.Status);
|
||||
|
||||
CreateEcPrivateKey(Path.Combine(tempDir, key2Relative));
|
||||
|
||||
var result = manager.Rotate(new SigningRotationRequest
|
||||
{
|
||||
KeyId = "key-2",
|
||||
Location = key2Relative
|
||||
});
|
||||
|
||||
Assert.Equal("key-2", result.ActiveKeyId);
|
||||
Assert.Equal("key-1", result.PreviousKeyId);
|
||||
Assert.Contains("key-1", result.RetiredKeyIds);
|
||||
|
||||
Assert.Equal("key-2", options.Signing.ActiveKeyId);
|
||||
var additional = Assert.Single(options.Signing.AdditionalKeys);
|
||||
Assert.Equal("key-1", additional.KeyId);
|
||||
Assert.Equal(key1Relative, additional.Path);
|
||||
Assert.Equal("file", additional.Source);
|
||||
|
||||
var afterRotation = jwksService.Build();
|
||||
Assert.Equal(2, afterRotation.Keys.Count);
|
||||
|
||||
var activeEntry = Assert.Single(afterRotation.Keys.Where(key => key.Status == AuthoritySigningKeyStatus.Active));
|
||||
Assert.Equal("key-2", activeEntry.Kid);
|
||||
|
||||
var retiredEntry = Assert.Single(afterRotation.Keys.Where(key => key.Status == AuthoritySigningKeyStatus.Retired));
|
||||
Assert.Equal("key-1", retiredEntry.Kid);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore cleanup failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ServiceProvider BuildProvider(string basePath, StellaOpsAuthorityOptions options)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
services.AddSingleton<IHostEnvironment>(new TestHostEnvironment(basePath));
|
||||
services.AddSingleton(options);
|
||||
services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(options));
|
||||
services.AddStellaOpsCrypto();
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthoritySigningKeySource, FileAuthoritySigningKeySource>());
|
||||
services.AddSingleton<AuthoritySigningKeyManager>();
|
||||
services.AddSingleton<AuthorityJwksService>();
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private static void CreateEcPrivateKey(string path)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var pem = ecdsa.ExportECPrivateKeyPem();
|
||||
File.WriteAllText(path, pem);
|
||||
}
|
||||
|
||||
private sealed class TestHostEnvironment : IHostEnvironment
|
||||
{
|
||||
public TestHostEnvironment(string contentRoot)
|
||||
{
|
||||
ContentRootPath = contentRoot;
|
||||
ContentRootFileProvider = new PhysicalFileProvider(contentRoot);
|
||||
EnvironmentName = Environments.Development;
|
||||
ApplicationName = "StellaOps.Authority.Tests";
|
||||
}
|
||||
|
||||
public string EnvironmentName { get; set; }
|
||||
|
||||
public string ApplicationName { get; set; }
|
||||
|
||||
public string ContentRootPath { get; set; }
|
||||
|
||||
public IFileProvider ContentRootFileProvider { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", ".
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Tests", "..\StellaOps.Cryptography.Tests\StellaOps.Cryptography.Tests.csproj", "{84AEC0C8-EE60-4AB1-A59B-B8E7CCFC0A25}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.DependencyInjection", "..\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj", "{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -363,6 +365,18 @@ Global
|
||||
{84AEC0C8-EE60-4AB1-A59B-B8E7CCFC0A25}.Release|x64.Build.0 = Release|Any CPU
|
||||
{84AEC0C8-EE60-4AB1-A59B-B8E7CCFC0A25}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{84AEC0C8-EE60-4AB1-A59B-B8E7CCFC0A25}.Release|x86.Build.0 = Release|Any CPU
|
||||
{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Release|x64.Build.0 = Release|Any CPU
|
||||
{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.Audit;
|
||||
|
||||
internal sealed class AuthorityAuditSink : IAuthEventSink
|
||||
{
|
||||
private static readonly StringComparer OrdinalComparer = StringComparer.Ordinal;
|
||||
|
||||
private readonly IAuthorityLoginAttemptStore loginAttemptStore;
|
||||
private readonly ILogger<AuthorityAuditSink> logger;
|
||||
|
||||
public AuthorityAuditSink(
|
||||
IAuthorityLoginAttemptStore loginAttemptStore,
|
||||
ILogger<AuthorityAuditSink> logger)
|
||||
{
|
||||
this.loginAttemptStore = loginAttemptStore ?? throw new ArgumentNullException(nameof(loginAttemptStore));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
var logState = BuildLogScope(record);
|
||||
using (logger.BeginScope(logState))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Authority audit event {EventType} emitted with outcome {Outcome}.",
|
||||
record.EventType,
|
||||
NormalizeOutcome(record.Outcome));
|
||||
}
|
||||
|
||||
var document = MapToDocument(record);
|
||||
await loginAttemptStore.InsertAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static AuthorityLoginAttemptDocument MapToDocument(AuthEventRecord record)
|
||||
{
|
||||
var document = new AuthorityLoginAttemptDocument
|
||||
{
|
||||
EventType = record.EventType,
|
||||
Outcome = NormalizeOutcome(record.Outcome),
|
||||
CorrelationId = Normalize(record.CorrelationId),
|
||||
SubjectId = record.Subject?.SubjectId.Value,
|
||||
Username = record.Subject?.Username.Value,
|
||||
ClientId = record.Client?.ClientId.Value,
|
||||
Plugin = record.Client?.Provider.Value,
|
||||
Successful = record.Outcome == AuthEventOutcome.Success,
|
||||
Reason = Normalize(record.Reason),
|
||||
RemoteAddress = record.Network?.RemoteAddress.Value ?? record.Network?.ForwardedFor.Value,
|
||||
OccurredAt = record.OccurredAt
|
||||
};
|
||||
|
||||
if (record.Scopes is { Count: > 0 })
|
||||
{
|
||||
document.Scopes = record.Scopes
|
||||
.Where(static scope => !string.IsNullOrWhiteSpace(scope))
|
||||
.Select(static scope => scope.Trim())
|
||||
.Where(static scope => scope.Length > 0)
|
||||
.Distinct(OrdinalComparer)
|
||||
.OrderBy(static scope => scope, OrdinalComparer)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var properties = new List<AuthorityLoginAttemptPropertyDocument>();
|
||||
|
||||
if (record.Subject is { } subject)
|
||||
{
|
||||
AddProperty(properties, "subject.display_name", subject.DisplayName);
|
||||
AddProperty(properties, "subject.realm", subject.Realm);
|
||||
|
||||
if (subject.Attributes is { Count: > 0 })
|
||||
{
|
||||
foreach (var attribute in subject.Attributes)
|
||||
{
|
||||
AddProperty(properties, $"subject.attr.{attribute.Name}", attribute.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (record.Client is { } client)
|
||||
{
|
||||
AddProperty(properties, "client.name", client.Name);
|
||||
}
|
||||
|
||||
if (record.Network is { } network)
|
||||
{
|
||||
AddProperty(properties, "network.remote", network.RemoteAddress);
|
||||
AddProperty(properties, "network.forwarded_for", network.ForwardedFor);
|
||||
AddProperty(properties, "network.user_agent", network.UserAgent);
|
||||
}
|
||||
|
||||
if (record.Properties is { Count: > 0 })
|
||||
{
|
||||
foreach (var property in record.Properties)
|
||||
{
|
||||
AddProperty(properties, property.Name, property.Value);
|
||||
}
|
||||
}
|
||||
|
||||
if (properties.Count > 0)
|
||||
{
|
||||
document.Properties = properties;
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<KeyValuePair<string, object?>> BuildLogScope(AuthEventRecord record)
|
||||
{
|
||||
var entries = new List<KeyValuePair<string, object?>>
|
||||
{
|
||||
new("audit.event_type", record.EventType),
|
||||
new("audit.outcome", NormalizeOutcome(record.Outcome)),
|
||||
new("audit.timestamp", record.OccurredAt.ToString("O", CultureInfo.InvariantCulture))
|
||||
};
|
||||
|
||||
AddValue(entries, "audit.correlation_id", Normalize(record.CorrelationId));
|
||||
AddValue(entries, "audit.reason", Normalize(record.Reason));
|
||||
|
||||
if (record.Subject is { } subject)
|
||||
{
|
||||
AddClassified(entries, "audit.subject.id", subject.SubjectId);
|
||||
AddClassified(entries, "audit.subject.username", subject.Username);
|
||||
AddClassified(entries, "audit.subject.display_name", subject.DisplayName);
|
||||
AddClassified(entries, "audit.subject.realm", subject.Realm);
|
||||
}
|
||||
|
||||
if (record.Client is { } client)
|
||||
{
|
||||
AddClassified(entries, "audit.client.id", client.ClientId);
|
||||
AddClassified(entries, "audit.client.name", client.Name);
|
||||
AddClassified(entries, "audit.client.provider", client.Provider);
|
||||
}
|
||||
|
||||
if (record.Network is { } network)
|
||||
{
|
||||
AddClassified(entries, "audit.network.remote", network.RemoteAddress);
|
||||
AddClassified(entries, "audit.network.forwarded_for", network.ForwardedFor);
|
||||
AddClassified(entries, "audit.network.user_agent", network.UserAgent);
|
||||
}
|
||||
|
||||
if (record.Scopes is { Count: > 0 })
|
||||
{
|
||||
entries.Add(new KeyValuePair<string, object?>(
|
||||
"audit.scopes",
|
||||
record.Scopes.Where(static scope => !string.IsNullOrWhiteSpace(scope)).ToArray()));
|
||||
}
|
||||
|
||||
if (record.Properties is { Count: > 0 })
|
||||
{
|
||||
foreach (var property in record.Properties)
|
||||
{
|
||||
AddClassified(entries, $"audit.property.{property.Name}", property.Value);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static void AddProperty(ICollection<AuthorityLoginAttemptPropertyDocument> properties, string name, ClassifiedString value)
|
||||
{
|
||||
if (!value.HasValue || string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
properties.Add(new AuthorityLoginAttemptPropertyDocument
|
||||
{
|
||||
Name = name,
|
||||
Value = value.Value,
|
||||
Classification = NormalizeClassification(value.Classification)
|
||||
});
|
||||
}
|
||||
|
||||
private static void AddValue(ICollection<KeyValuePair<string, object?>> entries, string key, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
entries.Add(new KeyValuePair<string, object?>(key, value));
|
||||
}
|
||||
|
||||
private static void AddClassified(ICollection<KeyValuePair<string, object?>> entries, string key, ClassifiedString value)
|
||||
{
|
||||
if (!value.HasValue || string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
entries.Add(new KeyValuePair<string, object?>(key, new
|
||||
{
|
||||
value.Value,
|
||||
classification = NormalizeClassification(value.Classification)
|
||||
}));
|
||||
}
|
||||
|
||||
private static string NormalizeOutcome(AuthEventOutcome outcome)
|
||||
=> outcome switch
|
||||
{
|
||||
AuthEventOutcome.Success => "success",
|
||||
AuthEventOutcome.Failure => "failure",
|
||||
AuthEventOutcome.LockedOut => "locked_out",
|
||||
AuthEventOutcome.RateLimited => "rate_limited",
|
||||
AuthEventOutcome.Error => "error",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
private static string NormalizeClassification(AuthEventDataClassification classification)
|
||||
=> classification switch
|
||||
{
|
||||
AuthEventDataClassification.Personal => "personal",
|
||||
AuthEventDataClassification.Sensitive => "sensitive",
|
||||
_ => "none"
|
||||
};
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
@@ -8,4 +8,11 @@ internal static class AuthorityOpenIddictConstants
|
||||
internal const string ClientProviderTransactionProperty = "authority:client_provider";
|
||||
internal const string ClientGrantedScopesProperty = "authority:client_granted_scopes";
|
||||
internal const string TokenTransactionProperty = "authority:token";
|
||||
internal const string AuditCorrelationProperty = "authority:audit_correlation_id";
|
||||
internal const string AuditClientIdProperty = "authority:audit_client_id";
|
||||
internal const string AuditProviderProperty = "authority:audit_provider";
|
||||
internal const string AuditConfidentialProperty = "authority:audit_confidential";
|
||||
internal const string AuditRequestedScopesProperty = "authority:audit_requested_scopes";
|
||||
internal const string AuditGrantedScopesProperty = "authority:audit_granted_scopes";
|
||||
internal const string AuditInvalidScopeProperty = "authority:audit_invalid_scope";
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
@@ -12,6 +15,8 @@ using StellaOps.Authority.OpenIddict;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
@@ -20,17 +25,26 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
private readonly IAuthorityClientStore clientStore;
|
||||
private readonly IAuthorityIdentityProviderRegistry registry;
|
||||
private readonly ActivitySource activitySource;
|
||||
private readonly IAuthEventSink auditSink;
|
||||
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<ValidateClientCredentialsHandler> logger;
|
||||
|
||||
public ValidateClientCredentialsHandler(
|
||||
IAuthorityClientStore clientStore,
|
||||
IAuthorityIdentityProviderRegistry registry,
|
||||
ActivitySource activitySource,
|
||||
IAuthEventSink auditSink,
|
||||
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<ValidateClientCredentialsHandler> logger)
|
||||
{
|
||||
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
|
||||
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
|
||||
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
|
||||
this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
@@ -48,13 +62,29 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
activity?.SetTag("authority.grant_type", OpenIddictConstants.GrantTypes.ClientCredentials);
|
||||
activity?.SetTag("authority.client_id", context.ClientId ?? string.Empty);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(context.ClientId))
|
||||
ClientCredentialsAuditHelper.EnsureCorrelationId(context.Transaction);
|
||||
|
||||
var metadata = metadataAccessor.GetMetadata();
|
||||
var clientId = context.ClientId ?? context.Request.ClientId;
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditClientIdProperty] = clientId;
|
||||
if (!string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Client identifier is required.");
|
||||
logger.LogWarning("Client credentials validation failed: missing client identifier.");
|
||||
return;
|
||||
metadataAccessor.SetClientId(clientId);
|
||||
}
|
||||
|
||||
var requestedScopeInput = context.Request.GetScopes();
|
||||
var requestedScopes = requestedScopeInput.IsDefaultOrEmpty ? Array.Empty<string>() : requestedScopeInput.ToArray();
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditRequestedScopesProperty] = requestedScopes;
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(context.ClientId))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Client identifier is required.");
|
||||
logger.LogWarning("Client credentials validation failed: missing client identifier.");
|
||||
return;
|
||||
}
|
||||
|
||||
var document = await clientStore.FindByClientIdAsync(context.ClientId, context.CancellationToken).ConfigureAwait(false);
|
||||
if (document is null || document.Disabled)
|
||||
{
|
||||
@@ -63,6 +93,8 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
return;
|
||||
}
|
||||
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditConfidentialProperty] = string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
IIdentityProviderPlugin? provider = null;
|
||||
if (!string.IsNullOrWhiteSpace(document.Plugin))
|
||||
{
|
||||
@@ -73,6 +105,8 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
return;
|
||||
}
|
||||
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditProviderProperty] = provider.Name;
|
||||
|
||||
if (!provider.Capabilities.SupportsClientProvisioning || provider.ClientProvisioning is null)
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.UnauthorizedClient, "Associated identity provider does not support client provisioning.");
|
||||
@@ -123,11 +157,14 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
|
||||
if (resolvedScopes.InvalidScope is not null)
|
||||
{
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = resolvedScopes.InvalidScope;
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidScope, $"Scope '{resolvedScopes.InvalidScope}' is not allowed for this client.");
|
||||
logger.LogWarning("Client credentials validation failed for {ClientId}: scope {Scope} not permitted.", document.ClientId, resolvedScopes.InvalidScope);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditGrantedScopesProperty] = resolvedScopes.Scopes;
|
||||
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty] = document;
|
||||
if (provider is not null)
|
||||
{
|
||||
@@ -138,6 +175,46 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty] = resolvedScopes.Scopes;
|
||||
logger.LogInformation("Client credentials validated for {ClientId}.", document.ClientId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
var outcome = context.IsRejected ? AuthEventOutcome.Failure : AuthEventOutcome.Success;
|
||||
var reason = context.IsRejected ? context.ErrorDescription : null;
|
||||
var auditClientId = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditClientIdProperty, out var clientValue)
|
||||
? clientValue as string
|
||||
: clientId;
|
||||
var providerName = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditProviderProperty, out var providerValue)
|
||||
? providerValue as string
|
||||
: null;
|
||||
var confidentialValue = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditConfidentialProperty, out var confidentialValueObj) && confidentialValueObj is bool conf
|
||||
? (bool?)conf
|
||||
: null;
|
||||
var requested = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditRequestedScopesProperty, out var requestedValue) && requestedValue is string[] requestedArray
|
||||
? (IReadOnlyList<string>)requestedArray
|
||||
: requestedScopes;
|
||||
var granted = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditGrantedScopesProperty, out var grantedValue) && grantedValue is string[] grantedArray
|
||||
? (IReadOnlyList<string>)grantedArray
|
||||
: Array.Empty<string>();
|
||||
var invalidScope = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditInvalidScopeProperty, out var invalidValue)
|
||||
? invalidValue as string
|
||||
: null;
|
||||
|
||||
var record = ClientCredentialsAuditHelper.CreateRecord(
|
||||
timeProvider,
|
||||
context.Transaction,
|
||||
metadata,
|
||||
null,
|
||||
outcome,
|
||||
reason,
|
||||
auditClientId,
|
||||
providerName,
|
||||
confidentialValue,
|
||||
requested,
|
||||
granted,
|
||||
invalidScope);
|
||||
|
||||
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleTokenRequestContext>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Extensions;
|
||||
@@ -7,6 +12,8 @@ using OpenIddict.Server;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
using StellaOps.Authority.OpenIddict;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
@@ -14,25 +21,34 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
|
||||
{
|
||||
private readonly IAuthorityIdentityProviderRegistry registry;
|
||||
private readonly ActivitySource activitySource;
|
||||
private readonly IAuthEventSink auditSink;
|
||||
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<ValidatePasswordGrantHandler> logger;
|
||||
|
||||
public ValidatePasswordGrantHandler(
|
||||
IAuthorityIdentityProviderRegistry registry,
|
||||
ActivitySource activitySource,
|
||||
IAuthEventSink auditSink,
|
||||
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<ValidatePasswordGrantHandler> logger)
|
||||
{
|
||||
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
|
||||
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
|
||||
this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context)
|
||||
public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!context.Request.IsPasswordGrantType())
|
||||
{
|
||||
return default;
|
||||
return;
|
||||
}
|
||||
|
||||
using var activity = activitySource.StartActivity("authority.token.validate_password_grant", ActivityKind.Internal);
|
||||
@@ -40,25 +56,72 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
|
||||
activity?.SetTag("authority.grant_type", OpenIddictConstants.GrantTypes.Password);
|
||||
activity?.SetTag("authority.username", context.Request.Username ?? string.Empty);
|
||||
|
||||
PasswordGrantAuditHelper.EnsureCorrelationId(context.Transaction);
|
||||
|
||||
var metadata = metadataAccessor.GetMetadata();
|
||||
var clientId = context.ClientId ?? context.Request.ClientId;
|
||||
if (!string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
metadataAccessor.SetClientId(clientId);
|
||||
}
|
||||
|
||||
var requestedScopesInput = context.Request.GetScopes();
|
||||
var requestedScopes = requestedScopesInput.IsDefaultOrEmpty ? Array.Empty<string>() : requestedScopesInput.ToArray();
|
||||
|
||||
var selection = AuthorityIdentityProviderSelector.ResolvePasswordProvider(context.Request, registry);
|
||||
if (!selection.Succeeded)
|
||||
{
|
||||
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
|
||||
timeProvider,
|
||||
context.Transaction,
|
||||
metadata,
|
||||
null,
|
||||
AuthEventOutcome.Failure,
|
||||
selection.Description,
|
||||
clientId,
|
||||
providerName: null,
|
||||
user: null,
|
||||
username: context.Request.Username,
|
||||
scopes: requestedScopes,
|
||||
retryAfter: null,
|
||||
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
|
||||
extraProperties: null);
|
||||
|
||||
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
context.Reject(selection.Error!, selection.Description);
|
||||
logger.LogWarning("Password grant validation failed for {Username}: {Reason}.", context.Request.Username, selection.Description);
|
||||
return default;
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(context.Request.Username) || string.IsNullOrEmpty(context.Request.Password))
|
||||
{
|
||||
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
|
||||
timeProvider,
|
||||
context.Transaction,
|
||||
metadata,
|
||||
httpContext,
|
||||
AuthEventOutcome.Failure,
|
||||
"Both username and password must be provided.",
|
||||
clientId,
|
||||
providerName: selection.Provider?.Name,
|
||||
user: null,
|
||||
username: context.Request.Username,
|
||||
scopes: requestedScopes,
|
||||
retryAfter: null,
|
||||
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
|
||||
extraProperties: null);
|
||||
|
||||
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Both username and password must be provided.");
|
||||
logger.LogWarning("Password grant validation failed: missing credentials for {Username}.", context.Request.Username);
|
||||
return default;
|
||||
return;
|
||||
}
|
||||
|
||||
context.Transaction.Properties[AuthorityOpenIddictConstants.ProviderTransactionProperty] = selection.Provider!.Name;
|
||||
activity?.SetTag("authority.identity_provider", selection.Provider.Name);
|
||||
logger.LogInformation("Password grant validation succeeded for {Username} using provider {Provider}.", context.Request.Username, selection.Provider.Name);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,15 +129,24 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
|
||||
{
|
||||
private readonly IAuthorityIdentityProviderRegistry registry;
|
||||
private readonly ActivitySource activitySource;
|
||||
private readonly IAuthEventSink auditSink;
|
||||
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<HandlePasswordGrantHandler> logger;
|
||||
|
||||
public HandlePasswordGrantHandler(
|
||||
IAuthorityIdentityProviderRegistry registry,
|
||||
ActivitySource activitySource,
|
||||
IAuthEventSink auditSink,
|
||||
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<HandlePasswordGrantHandler> logger)
|
||||
{
|
||||
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
|
||||
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
|
||||
this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
@@ -92,6 +164,18 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
|
||||
activity?.SetTag("authority.grant_type", OpenIddictConstants.GrantTypes.Password);
|
||||
activity?.SetTag("authority.username", context.Request.Username ?? string.Empty);
|
||||
|
||||
PasswordGrantAuditHelper.EnsureCorrelationId(context.Transaction);
|
||||
|
||||
var metadata = metadataAccessor.GetMetadata();
|
||||
var clientId = context.ClientId ?? context.Request.ClientId;
|
||||
if (!string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
metadataAccessor.SetClientId(clientId);
|
||||
}
|
||||
|
||||
var requestedScopesInput = context.Request.GetScopes();
|
||||
var requestedScopes = requestedScopesInput.IsDefaultOrEmpty ? Array.Empty<string>() : requestedScopesInput.ToArray();
|
||||
|
||||
var providerName = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ProviderTransactionProperty, out var value)
|
||||
? value as string
|
||||
: null;
|
||||
@@ -101,6 +185,23 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
|
||||
{
|
||||
if (!registry.TryGet(providerName!, out var explicitProvider))
|
||||
{
|
||||
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
|
||||
timeProvider,
|
||||
context.Transaction,
|
||||
metadata,
|
||||
AuthEventOutcome.Failure,
|
||||
"Unable to resolve the requested identity provider.",
|
||||
clientId,
|
||||
providerName,
|
||||
user: null,
|
||||
username: context.Request.Username,
|
||||
scopes: requestedScopes,
|
||||
retryAfter: null,
|
||||
failureCode: AuthorityCredentialFailureCode.UnknownError,
|
||||
extraProperties: null);
|
||||
|
||||
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
context.Reject(OpenIddictConstants.Errors.ServerError, "Unable to resolve the requested identity provider.");
|
||||
logger.LogError("Password grant handling failed: provider {Provider} not found for user {Username}.", providerName, context.Request.Username);
|
||||
return;
|
||||
@@ -113,12 +214,30 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
|
||||
var selection = AuthorityIdentityProviderSelector.ResolvePasswordProvider(context.Request, registry);
|
||||
if (!selection.Succeeded)
|
||||
{
|
||||
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
|
||||
timeProvider,
|
||||
context.Transaction,
|
||||
metadata,
|
||||
AuthEventOutcome.Failure,
|
||||
selection.Description,
|
||||
clientId,
|
||||
providerName: null,
|
||||
user: null,
|
||||
username: context.Request.Username,
|
||||
scopes: requestedScopes,
|
||||
retryAfter: null,
|
||||
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
|
||||
extraProperties: null);
|
||||
|
||||
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
context.Reject(selection.Error!, selection.Description);
|
||||
logger.LogWarning("Password grant handling rejected {Username}: {Reason}.", context.Request.Username, selection.Description);
|
||||
return;
|
||||
}
|
||||
|
||||
resolvedProvider = selection.Provider;
|
||||
providerName = selection.Provider?.Name;
|
||||
}
|
||||
|
||||
var provider = resolvedProvider ?? throw new InvalidOperationException("No identity provider resolved for password grant.");
|
||||
@@ -127,6 +246,24 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
|
||||
var password = context.Request.Password;
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password))
|
||||
{
|
||||
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
|
||||
timeProvider,
|
||||
context.Transaction,
|
||||
metadata,
|
||||
httpContext,
|
||||
AuthEventOutcome.Failure,
|
||||
"Both username and password must be provided.",
|
||||
clientId,
|
||||
provider.Name,
|
||||
user: null,
|
||||
username: username,
|
||||
scopes: requestedScopes,
|
||||
retryAfter: null,
|
||||
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
|
||||
extraProperties: null);
|
||||
|
||||
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Both username and password must be provided.");
|
||||
logger.LogWarning("Password grant handling rejected: missing credentials for {Username}.", username);
|
||||
return;
|
||||
@@ -139,6 +276,27 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
|
||||
|
||||
if (!verification.Succeeded || verification.User is null)
|
||||
{
|
||||
var outcome = verification.FailureCode == AuthorityCredentialFailureCode.LockedOut
|
||||
? AuthEventOutcome.LockedOut
|
||||
: AuthEventOutcome.Failure;
|
||||
|
||||
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
|
||||
timeProvider,
|
||||
context.Transaction,
|
||||
metadata,
|
||||
outcome,
|
||||
verification.Message,
|
||||
clientId,
|
||||
provider.Name,
|
||||
verification.User,
|
||||
username,
|
||||
scopes: requestedScopes,
|
||||
retryAfter: verification.RetryAfter,
|
||||
failureCode: verification.FailureCode,
|
||||
extraProperties: verification.AuditProperties);
|
||||
|
||||
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
context.Reject(
|
||||
OpenIddictConstants.Errors.InvalidGrant,
|
||||
verification.Message ?? "Invalid username or password.");
|
||||
@@ -146,6 +304,8 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
|
||||
return;
|
||||
}
|
||||
|
||||
metadataAccessor.SetSubjectId(verification.User.SubjectId);
|
||||
|
||||
var identity = new ClaimsIdentity(
|
||||
OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
|
||||
OpenIddictConstants.Claims.Name,
|
||||
@@ -179,9 +339,246 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
|
||||
var enrichmentContext = new AuthorityClaimsEnrichmentContext(provider.Context, verification.User, null);
|
||||
await provider.ClaimsEnricher.EnrichAsync(identity, enrichmentContext, context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
var successRecord = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
|
||||
timeProvider,
|
||||
context.Transaction,
|
||||
metadata,
|
||||
AuthEventOutcome.Success,
|
||||
verification.Message,
|
||||
clientId,
|
||||
provider.Name,
|
||||
verification.User,
|
||||
username,
|
||||
scopes: requestedScopes,
|
||||
retryAfter: null,
|
||||
failureCode: null,
|
||||
extraProperties: verification.AuditProperties);
|
||||
|
||||
await auditSink.WriteAsync(successRecord, context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
context.Principal = principal;
|
||||
context.HandleRequest();
|
||||
activity?.SetTag("authority.subject_id", verification.User.SubjectId);
|
||||
logger.LogInformation("Password grant issued for {Username} with subject {SubjectId}.", verification.User.Username, verification.User.SubjectId);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class PasswordGrantAuditHelper
|
||||
{
|
||||
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 CreatePasswordGrantRecord(
|
||||
TimeProvider timeProvider,
|
||||
OpenIddictServerTransaction transaction,
|
||||
AuthorityRateLimiterMetadata? metadata,
|
||||
AuthEventOutcome outcome,
|
||||
string? reason,
|
||||
string? clientId,
|
||||
string? providerName,
|
||||
AuthorityUserDescriptor? user,
|
||||
string? username,
|
||||
IEnumerable<string>? scopes,
|
||||
TimeSpan? retryAfter,
|
||||
AuthorityCredentialFailureCode? failureCode,
|
||||
IEnumerable<AuthEventProperty>? extraProperties)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
ArgumentNullException.ThrowIfNull(transaction);
|
||||
|
||||
var correlationId = EnsureCorrelationId(transaction);
|
||||
var normalizedScopes = NormalizeScopes(scopes);
|
||||
var subject = BuildSubject(user, username, providerName);
|
||||
var client = BuildClient(clientId, providerName);
|
||||
var network = BuildNetwork(metadata);
|
||||
var properties = BuildProperties(user, retryAfter, failureCode, extraProperties);
|
||||
|
||||
return new AuthEventRecord
|
||||
{
|
||||
EventType = "authority.password.grant",
|
||||
OccurredAt = timeProvider.GetUtcNow(),
|
||||
CorrelationId = correlationId,
|
||||
Outcome = outcome,
|
||||
Reason = Normalize(reason),
|
||||
Subject = subject,
|
||||
Client = client,
|
||||
Scopes = normalizedScopes,
|
||||
Network = network,
|
||||
Properties = properties
|
||||
};
|
||||
}
|
||||
|
||||
private static AuthEventSubject? BuildSubject(AuthorityUserDescriptor? user, string? username, string? providerName)
|
||||
{
|
||||
var attributes = user?.Attributes;
|
||||
var normalizedUsername = Normalize(username) ?? Normalize(user?.Username);
|
||||
var subjectId = Normalize(user?.SubjectId);
|
||||
var displayName = Normalize(user?.DisplayName);
|
||||
var attributeProperties = BuildSubjectAttributes(attributes);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(subjectId) &&
|
||||
string.IsNullOrWhiteSpace(normalizedUsername) &&
|
||||
string.IsNullOrWhiteSpace(displayName) &&
|
||||
attributeProperties.Count == 0 &&
|
||||
string.IsNullOrWhiteSpace(providerName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthEventSubject
|
||||
{
|
||||
SubjectId = ClassifiedString.Personal(subjectId),
|
||||
Username = ClassifiedString.Personal(normalizedUsername),
|
||||
DisplayName = ClassifiedString.Personal(displayName),
|
||||
Realm = ClassifiedString.Public(Normalize(providerName)),
|
||||
Attributes = attributeProperties
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AuthEventProperty> BuildSubjectAttributes(IReadOnlyDictionary<string, string?>? attributes)
|
||||
{
|
||||
if (attributes is null || attributes.Count == 0)
|
||||
{
|
||||
return Array.Empty<AuthEventProperty>();
|
||||
}
|
||||
|
||||
var items = new List<AuthEventProperty>(attributes.Count);
|
||||
foreach (var pair in attributes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
items.Add(new AuthEventProperty
|
||||
{
|
||||
Name = pair.Key,
|
||||
Value = ClassifiedString.Personal(Normalize(pair.Value))
|
||||
});
|
||||
}
|
||||
|
||||
return items.Count == 0 ? Array.Empty<AuthEventProperty>() : items;
|
||||
}
|
||||
|
||||
private static AuthEventClient? BuildClient(string? clientId, string? providerName)
|
||||
{
|
||||
var normalizedClientId = Normalize(clientId);
|
||||
var provider = Normalize(providerName);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(normalizedClientId) && string.IsNullOrWhiteSpace(provider))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthEventClient
|
||||
{
|
||||
ClientId = ClassifiedString.Personal(normalizedClientId),
|
||||
Name = ClassifiedString.Empty,
|
||||
Provider = ClassifiedString.Public(provider)
|
||||
};
|
||||
}
|
||||
|
||||
private static AuthEventNetwork? BuildNetwork(AuthorityRateLimiterMetadata? metadata)
|
||||
{
|
||||
var remote = Normalize(metadata?.RemoteIp);
|
||||
var forwarded = Normalize(metadata?.ForwardedFor);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(remote) && string.IsNullOrWhiteSpace(forwarded))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthEventNetwork
|
||||
{
|
||||
RemoteAddress = ClassifiedString.Personal(remote),
|
||||
ForwardedFor = ClassifiedString.Personal(forwarded)
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AuthEventProperty> BuildProperties(
|
||||
AuthorityUserDescriptor? user,
|
||||
TimeSpan? retryAfter,
|
||||
AuthorityCredentialFailureCode? failureCode,
|
||||
IEnumerable<AuthEventProperty>? extraProperties)
|
||||
{
|
||||
var properties = new List<AuthEventProperty>();
|
||||
|
||||
if (failureCode is { } code)
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "failure.code",
|
||||
Value = ClassifiedString.Public(code.ToString())
|
||||
});
|
||||
}
|
||||
|
||||
if (retryAfter is { } retry && retry > TimeSpan.Zero)
|
||||
{
|
||||
var seconds = Math.Ceiling(retry.TotalSeconds).ToString(CultureInfo.InvariantCulture);
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "policy.retry_after_seconds",
|
||||
Value = ClassifiedString.Public(seconds)
|
||||
});
|
||||
}
|
||||
|
||||
if (user is not null)
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "subject.requires_password_reset",
|
||||
Value = ClassifiedString.Public(user.RequiresPasswordReset ? "true" : "false")
|
||||
});
|
||||
}
|
||||
|
||||
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(IEnumerable<string>? scopes)
|
||||
{
|
||||
if (scopes is null)
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
internal sealed class HandleRevocationRequestHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleRevocationRequestContext>
|
||||
{
|
||||
private readonly IAuthorityTokenStore tokenStore;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ILogger<HandleRevocationRequestHandler> logger;
|
||||
private readonly ActivitySource activitySource;
|
||||
|
||||
public HandleRevocationRequestHandler(
|
||||
IAuthorityTokenStore tokenStore,
|
||||
TimeProvider clock,
|
||||
ActivitySource activitySource,
|
||||
ILogger<HandleRevocationRequestHandler> logger)
|
||||
{
|
||||
this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask HandleAsync(OpenIddictServerEvents.HandleRevocationRequestContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
using var activity = activitySource.StartActivity("authority.token.revoke", ActivityKind.Internal);
|
||||
|
||||
var request = context.Request;
|
||||
if (request is null || string.IsNullOrWhiteSpace(request.Token))
|
||||
{
|
||||
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "The revocation request is missing the token parameter.");
|
||||
return;
|
||||
}
|
||||
|
||||
var token = request.Token.Trim();
|
||||
var document = await tokenStore.FindByTokenIdAsync(token, context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (document is null)
|
||||
{
|
||||
var tokenId = TryExtractTokenId(token);
|
||||
if (!string.IsNullOrWhiteSpace(tokenId))
|
||||
{
|
||||
document = await tokenStore.FindByTokenIdAsync(tokenId!, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (document is null)
|
||||
{
|
||||
logger.LogDebug("Revocation request for unknown token ignored.");
|
||||
context.HandleRequest();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(document.Status, "revoked", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await tokenStore.UpdateStatusAsync(
|
||||
document.TokenId,
|
||||
"revoked",
|
||||
clock.GetUtcNow(),
|
||||
"client_request",
|
||||
null,
|
||||
null,
|
||||
context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
logger.LogInformation("Token {TokenId} revoked via revocation endpoint.", document.TokenId);
|
||||
activity?.SetTag("authority.token_id", document.TokenId);
|
||||
}
|
||||
|
||||
context.HandleRequest();
|
||||
}
|
||||
|
||||
private static string? TryExtractTokenId(string token)
|
||||
{
|
||||
var parts = token.Split('.');
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payload = Base64UrlDecode(parts[1]);
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
if (document.RootElement.TryGetProperty("jti", out var jti) && jti.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = jti.GetString();
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[] Base64UrlDecode(string value)
|
||||
{
|
||||
var padded = value.Length % 4 switch
|
||||
{
|
||||
2 => value + "==",
|
||||
3 => value + "=",
|
||||
_ => value
|
||||
};
|
||||
|
||||
padded = padded.Replace('-', '+').Replace('_', '/');
|
||||
return Convert.FromBase64String(padded);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Extensions;
|
||||
using OpenIddict.Server;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ProcessSignInContext>
|
||||
{
|
||||
private readonly IAuthorityTokenStore tokenStore;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ActivitySource activitySource;
|
||||
private readonly ILogger<PersistTokensHandler> logger;
|
||||
|
||||
public PersistTokensHandler(
|
||||
IAuthorityTokenStore tokenStore,
|
||||
TimeProvider clock,
|
||||
ActivitySource activitySource,
|
||||
ILogger<PersistTokensHandler> logger)
|
||||
{
|
||||
this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask HandleAsync(OpenIddictServerEvents.ProcessSignInContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (context.AccessTokenPrincipal is null &&
|
||||
context.RefreshTokenPrincipal is null &&
|
||||
context.AuthorizationCodePrincipal is null &&
|
||||
context.DeviceCodePrincipal is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var activity = activitySource.StartActivity("authority.token.persist", ActivityKind.Internal);
|
||||
var issuedAt = clock.GetUtcNow();
|
||||
|
||||
if (context.AccessTokenPrincipal is ClaimsPrincipal accessPrincipal)
|
||||
{
|
||||
await PersistAsync(accessPrincipal, OpenIddictConstants.TokenTypeHints.AccessToken, issuedAt, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (context.RefreshTokenPrincipal is ClaimsPrincipal refreshPrincipal)
|
||||
{
|
||||
await PersistAsync(refreshPrincipal, OpenIddictConstants.TokenTypeHints.RefreshToken, issuedAt, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (context.AuthorizationCodePrincipal is ClaimsPrincipal authorizationPrincipal)
|
||||
{
|
||||
await PersistAsync(authorizationPrincipal, OpenIddictConstants.TokenTypeHints.AuthorizationCode, issuedAt, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (context.DeviceCodePrincipal is ClaimsPrincipal devicePrincipal)
|
||||
{
|
||||
await PersistAsync(devicePrincipal, OpenIddictConstants.TokenTypeHints.DeviceCode, issuedAt, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask PersistAsync(ClaimsPrincipal principal, string tokenType, DateTimeOffset issuedAt, CancellationToken cancellationToken)
|
||||
{
|
||||
var tokenId = EnsureTokenId(principal);
|
||||
var scopes = ExtractScopes(principal);
|
||||
var document = new AuthorityTokenDocument
|
||||
{
|
||||
TokenId = tokenId,
|
||||
Type = tokenType,
|
||||
SubjectId = principal.GetClaim(OpenIddictConstants.Claims.Subject),
|
||||
ClientId = principal.GetClaim(OpenIddictConstants.Claims.ClientId),
|
||||
Scope = scopes,
|
||||
Status = "valid",
|
||||
CreatedAt = issuedAt,
|
||||
ExpiresAt = TryGetExpiration(principal)
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await tokenStore.InsertAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
logger.LogDebug("Persisted {Type} token {TokenId} for client {ClientId}.", tokenType, tokenId, document.ClientId ?? "<none>");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to persist {Type} token {TokenId}.", tokenType, tokenId);
|
||||
}
|
||||
}
|
||||
|
||||
private static string EnsureTokenId(ClaimsPrincipal principal)
|
||||
{
|
||||
var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId);
|
||||
if (string.IsNullOrWhiteSpace(tokenId))
|
||||
{
|
||||
tokenId = Guid.NewGuid().ToString("N");
|
||||
principal.SetClaim(OpenIddictConstants.Claims.JwtId, tokenId);
|
||||
}
|
||||
|
||||
return tokenId;
|
||||
}
|
||||
|
||||
private static List<string> ExtractScopes(ClaimsPrincipal principal)
|
||||
=> principal.GetScopes()
|
||||
.Where(scope => !string.IsNullOrWhiteSpace(scope))
|
||||
.Select(scope => scope.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(scope => scope, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
private static DateTimeOffset? TryGetExpiration(ClaimsPrincipal principal)
|
||||
{
|
||||
var value = principal.GetClaim(OpenIddictConstants.Claims.Exp);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds))
|
||||
{
|
||||
return DateTimeOffset.FromUnixTimeSeconds(seconds);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -14,16 +18,23 @@ using MongoDB.Driver;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using StellaOps.Authority;
|
||||
using StellaOps.Authority.Audit;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugins;
|
||||
using StellaOps.Authority.Bootstrap;
|
||||
using StellaOps.Authority.Storage.Mongo.Extensions;
|
||||
using StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Plugin.DependencyInjection;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
using StellaOps.Authority.OpenIddict.Handlers;
|
||||
using System.Linq;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.Authority.Revocation;
|
||||
using StellaOps.Authority.Signing;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -68,12 +79,20 @@ var authorityOptions = authorityConfiguration.Options;
|
||||
var issuer = authorityOptions.Issuer ?? throw new InvalidOperationException("Authority issuer configuration is required.");
|
||||
builder.Services.AddSingleton(authorityOptions);
|
||||
builder.Services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(authorityOptions));
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System);
|
||||
builder.Services.TryAddSingleton<IAuthorityRateLimiterMetadataAccessor, AuthorityRateLimiterMetadataAccessor>();
|
||||
builder.Services.TryAddSingleton<IAuthorityRateLimiterPartitionKeyResolver, DefaultAuthorityRateLimiterPartitionKeyResolver>();
|
||||
|
||||
builder.Services.AddRateLimiter(rateLimiterOptions =>
|
||||
{
|
||||
AuthorityRateLimiter.Configure(rateLimiterOptions, authorityOptions);
|
||||
});
|
||||
|
||||
builder.Services.AddStellaOpsCrypto();
|
||||
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthoritySigningKeySource, FileAuthoritySigningKeySource>());
|
||||
builder.Services.AddSingleton<AuthoritySigningKeyManager>();
|
||||
|
||||
AuthorityPluginContext[] pluginContexts = AuthorityPluginConfigurationLoader
|
||||
.Load(authorityOptions, builder.Environment.ContentRootPath)
|
||||
.ToArray();
|
||||
@@ -93,11 +112,18 @@ builder.Services.AddAuthorityMongoStorage(storageOptions =>
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<IAuthorityIdentityProviderRegistry, AuthorityIdentityProviderRegistry>();
|
||||
builder.Services.AddSingleton<IAuthEventSink, AuthorityAuditSink>();
|
||||
builder.Services.AddScoped<ValidatePasswordGrantHandler>();
|
||||
builder.Services.AddScoped<HandlePasswordGrantHandler>();
|
||||
builder.Services.AddScoped<ValidateClientCredentialsHandler>();
|
||||
builder.Services.AddScoped<HandleClientCredentialsHandler>();
|
||||
builder.Services.AddScoped<ValidateAccessTokenHandler>();
|
||||
builder.Services.AddScoped<PersistTokensHandler>();
|
||||
builder.Services.AddScoped<HandleRevocationRequestHandler>();
|
||||
builder.Services.AddSingleton<RevocationBundleBuilder>();
|
||||
builder.Services.AddSingleton<RevocationBundleSigner>();
|
||||
builder.Services.AddSingleton<AuthorityRevocationExportService>();
|
||||
builder.Services.AddSingleton<AuthorityJwksService>();
|
||||
|
||||
var pluginRegistrationSummary = AuthorityPluginLoader.RegisterPlugins(
|
||||
builder.Services,
|
||||
@@ -179,6 +205,16 @@ builder.Services.AddOpenIddict()
|
||||
{
|
||||
descriptor.UseScopedHandler<ValidateAccessTokenHandler>();
|
||||
});
|
||||
|
||||
options.AddEventHandler<OpenIddictServerEvents.ProcessSignInContext>(descriptor =>
|
||||
{
|
||||
descriptor.UseScopedHandler<PersistTokensHandler>();
|
||||
});
|
||||
|
||||
options.AddEventHandler<OpenIddictServerEvents.HandleRevocationRequestContext>(descriptor =>
|
||||
{
|
||||
descriptor.UseScopedHandler<HandleRevocationRequestHandler>();
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.Configure<OpenIddictServerOptions>(options =>
|
||||
@@ -242,12 +278,16 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
bootstrapGroup.AddEndpointFilter(new BootstrapApiKeyFilter(authorityOptions));
|
||||
|
||||
bootstrapGroup.MapPost("/users", async (
|
||||
HttpContext httpContext,
|
||||
BootstrapUserRequest request,
|
||||
IAuthorityIdentityProviderRegistry registry,
|
||||
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);
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "Request payload is required." });
|
||||
}
|
||||
|
||||
@@ -257,16 +297,19 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "Username and password are required." });
|
||||
}
|
||||
|
||||
@@ -288,24 +331,88 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
|
||||
if (!result.Succeeded || result.Value is null)
|
||||
{
|
||||
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." });
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var correlationId = Activity.Current?.TraceId.ToString() ?? httpContext.TraceIdentifier ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
|
||||
AuthEventNetwork? network = null;
|
||||
var remoteAddress = httpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var userAgent = httpContext.Request.Headers.UserAgent.ToString();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(remoteAddress) || !string.IsNullOrWhiteSpace(userAgent))
|
||||
{
|
||||
network = new AuthEventNetwork
|
||||
{
|
||||
RemoteAddress = ClassifiedString.Personal(remoteAddress),
|
||||
UserAgent = ClassifiedString.Personal(string.IsNullOrWhiteSpace(userAgent) ? null : userAgent)
|
||||
};
|
||||
}
|
||||
|
||||
var subject = subjectId is null && string.IsNullOrWhiteSpace(usernameValue) && string.IsNullOrWhiteSpace(providerValue)
|
||||
? null
|
||||
: new AuthEventSubject
|
||||
{
|
||||
SubjectId = ClassifiedString.Personal(subjectId),
|
||||
Username = ClassifiedString.Personal(usernameValue),
|
||||
Realm = ClassifiedString.Public(providerValue)
|
||||
};
|
||||
|
||||
var properties = string.IsNullOrWhiteSpace(providerValue)
|
||||
? Array.Empty<AuthEventProperty>()
|
||||
: new[]
|
||||
{
|
||||
new AuthEventProperty
|
||||
{
|
||||
Name = "bootstrap.provider",
|
||||
Value = ClassifiedString.Public(providerValue)
|
||||
}
|
||||
};
|
||||
|
||||
var scopes = rolesValue is { Count: > 0 }
|
||||
? rolesValue.ToArray()
|
||||
: Array.Empty<string>();
|
||||
|
||||
var record = new AuthEventRecord
|
||||
{
|
||||
EventType = "authority.bootstrap.user",
|
||||
OccurredAt = timeProvider.GetUtcNow(),
|
||||
CorrelationId = correlationId,
|
||||
Outcome = outcome,
|
||||
Reason = reason,
|
||||
Subject = subject,
|
||||
Client = null,
|
||||
Scopes = scopes,
|
||||
Network = network,
|
||||
Properties = properties
|
||||
};
|
||||
|
||||
await auditSink.WriteAsync(record, httpContext.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
});
|
||||
|
||||
bootstrapGroup.MapPost("/clients", async (
|
||||
HttpContext httpContext,
|
||||
BootstrapClientRequest request,
|
||||
IAuthorityIdentityProviderRegistry registry,
|
||||
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);
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "Request payload is required." });
|
||||
}
|
||||
|
||||
@@ -315,31 +422,37 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "ClientId is required." });
|
||||
}
|
||||
|
||||
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);
|
||||
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 });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -362,15 +475,151 @@ 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);
|
||||
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);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
provider = provider.Name,
|
||||
clientId = result.Value.ClientId,
|
||||
confidential = result.Value.Confidential
|
||||
});
|
||||
|
||||
async Task WriteBootstrapClientAuditAsync(AuthEventOutcome outcome, string? reason, string? requestedClientId, string? assignedClientId, string? providerValue, IReadOnlyCollection<string> scopes, bool? confidentialFlag)
|
||||
{
|
||||
var correlationId = Activity.Current?.TraceId.ToString() ?? httpContext.TraceIdentifier ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
|
||||
AuthEventNetwork? network = null;
|
||||
var remoteAddress = httpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var userAgent = httpContext.Request.Headers.UserAgent.ToString();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(remoteAddress) || !string.IsNullOrWhiteSpace(userAgent))
|
||||
{
|
||||
network = new AuthEventNetwork
|
||||
{
|
||||
RemoteAddress = ClassifiedString.Personal(remoteAddress),
|
||||
UserAgent = ClassifiedString.Personal(string.IsNullOrWhiteSpace(userAgent) ? null : userAgent)
|
||||
};
|
||||
}
|
||||
|
||||
var clientIdValue = assignedClientId ?? requestedClientId;
|
||||
var client = clientIdValue is null && string.IsNullOrWhiteSpace(providerValue)
|
||||
? null
|
||||
: new AuthEventClient
|
||||
{
|
||||
ClientId = ClassifiedString.Personal(clientIdValue),
|
||||
Name = ClassifiedString.Empty,
|
||||
Provider = ClassifiedString.Public(providerValue)
|
||||
};
|
||||
|
||||
var properties = new List<AuthEventProperty>();
|
||||
if (!string.IsNullOrWhiteSpace(requestedClientId) && !string.Equals(requestedClientId, assignedClientId, StringComparison.Ordinal))
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "bootstrap.requested_client_id",
|
||||
Value = ClassifiedString.Public(requestedClientId)
|
||||
});
|
||||
}
|
||||
|
||||
if (confidentialFlag == true)
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = "bootstrap.confidential",
|
||||
Value = ClassifiedString.Public("true")
|
||||
});
|
||||
}
|
||||
|
||||
var record = new AuthEventRecord
|
||||
{
|
||||
EventType = "authority.bootstrap.client",
|
||||
OccurredAt = timeProvider.GetUtcNow(),
|
||||
CorrelationId = correlationId,
|
||||
Outcome = outcome,
|
||||
Reason = reason,
|
||||
Subject = null,
|
||||
Client = client,
|
||||
Scopes = scopes is { Count: > 0 } ? scopes.ToArray() : Array.Empty<string>(),
|
||||
Network = network,
|
||||
Properties = properties.Count == 0 ? Array.Empty<AuthEventProperty>() : properties.ToArray()
|
||||
};
|
||||
|
||||
await auditSink.WriteAsync(record, httpContext.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
});
|
||||
|
||||
bootstrapGroup.MapGet("/revocations/export", async (
|
||||
AuthorityRevocationExportService exportService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var package = await exportService.ExportAsync(cancellationToken).ConfigureAwait(false);
|
||||
var build = package.Bundle;
|
||||
|
||||
var response = new RevocationExportResponse
|
||||
{
|
||||
SchemaVersion = build.Bundle.SchemaVersion,
|
||||
BundleId = build.Bundle.BundleId ?? build.Sha256,
|
||||
Sequence = build.Sequence,
|
||||
IssuedAt = build.IssuedAt,
|
||||
SigningKeyId = package.Signature.KeyId,
|
||||
Bundle = new RevocationExportPayload
|
||||
{
|
||||
Data = Convert.ToBase64String(build.CanonicalJson)
|
||||
},
|
||||
Signature = new RevocationExportSignature
|
||||
{
|
||||
Algorithm = package.Signature.Algorithm,
|
||||
KeyId = package.Signature.KeyId,
|
||||
Value = package.Signature.Value
|
||||
},
|
||||
Digest = new RevocationExportDigest
|
||||
{
|
||||
Value = build.Sha256
|
||||
}
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
});
|
||||
|
||||
bootstrapGroup.MapPost("/signing/rotate", (
|
||||
SigningRotationRequest? request,
|
||||
AuthoritySigningKeyManager signingManager,
|
||||
ILogger<AuthoritySigningKeyManager> signingLogger) =>
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
signingLogger.LogWarning("Signing rotation request payload missing.");
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "Request payload is required." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = signingManager.Rotate(request);
|
||||
signingLogger.LogInformation("Signing key rotation completed. Active key {KeyId}.", result.ActiveKeyId);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
activeKeyId = result.ActiveKeyId,
|
||||
provider = result.ActiveProvider,
|
||||
source = result.ActiveSource,
|
||||
location = result.ActiveLocation,
|
||||
previousKeyId = result.PreviousKeyId,
|
||||
retiredKeyIds = result.RetiredKeyIds
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
signingLogger.LogWarning(ex, "Signing rotation failed due to invalid input.");
|
||||
return Results.BadRequest(new { error = "rotation_failed", message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
signingLogger.LogError(ex, "Unexpected failure rotating signing key.");
|
||||
return Results.Problem("Failed to rotate signing key.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -398,6 +647,7 @@ app.UseExceptionHandler(static errorApp =>
|
||||
});
|
||||
|
||||
app.UseRouting();
|
||||
app.UseAuthorityRateLimiterContext();
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
@@ -432,6 +682,12 @@ app.MapGet("/ready", (IAuthorityIdentityProviderRegistry registry) =>
|
||||
}))
|
||||
.WithName("ReadinessCheck");
|
||||
|
||||
app.MapGet("/jwks", (AuthorityJwksService jwksService) => Results.Ok(jwksService.Build()))
|
||||
.WithName("JsonWebKeySet");
|
||||
|
||||
// Ensure signing key manager initialises key material on startup.
|
||||
app.Services.GetRequiredService<AuthoritySigningKeyManager>();
|
||||
|
||||
app.Run();
|
||||
|
||||
static PluginHostOptions BuildPluginHostOptions(StellaOpsAuthorityOptions options, string basePath)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Authority.Revocation;
|
||||
|
||||
internal sealed class AuthorityRevocationExportService
|
||||
{
|
||||
private readonly RevocationBundleBuilder bundleBuilder;
|
||||
private readonly RevocationBundleSigner signer;
|
||||
private readonly ILogger<AuthorityRevocationExportService> logger;
|
||||
|
||||
public AuthorityRevocationExportService(
|
||||
RevocationBundleBuilder bundleBuilder,
|
||||
RevocationBundleSigner signer,
|
||||
ILogger<AuthorityRevocationExportService> logger)
|
||||
{
|
||||
this.bundleBuilder = bundleBuilder ?? throw new ArgumentNullException(nameof(bundleBuilder));
|
||||
this.signer = signer ?? throw new ArgumentNullException(nameof(signer));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<RevocationExportPackage> ExportAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var buildResult = await bundleBuilder.BuildAsync(cancellationToken).ConfigureAwait(false);
|
||||
var signature = await signer.SignAsync(buildResult.CanonicalJson, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
logger.LogInformation(
|
||||
"Generated revocation bundle sequence {Sequence} with {EntryCount} entries (sha256:{Hash}).",
|
||||
buildResult.Sequence,
|
||||
buildResult.Bundle.Revocations.Count,
|
||||
buildResult.Sha256);
|
||||
|
||||
return new RevocationExportPackage(buildResult, signature);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Authority.Revocation;
|
||||
|
||||
internal sealed record RevocationBundleBuildResult(
|
||||
RevocationBundleModel Bundle,
|
||||
byte[] CanonicalJson,
|
||||
string Sha256,
|
||||
long Sequence,
|
||||
DateTimeOffset IssuedAt);
|
||||
@@ -0,0 +1,220 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Configuration;
|
||||
|
||||
namespace StellaOps.Authority.Revocation;
|
||||
|
||||
internal sealed class RevocationBundleBuilder
|
||||
{
|
||||
private const string SchemaVersion = "1.0.0";
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General)
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly IAuthorityTokenStore tokenStore;
|
||||
private readonly IAuthorityRevocationStore revocationStore;
|
||||
private readonly IAuthorityRevocationExportStateStore stateStore;
|
||||
private readonly StellaOpsAuthorityOptions authorityOptions;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ILogger<RevocationBundleBuilder> logger;
|
||||
|
||||
public RevocationBundleBuilder(
|
||||
IAuthorityTokenStore tokenStore,
|
||||
IAuthorityRevocationStore revocationStore,
|
||||
IAuthorityRevocationExportStateStore stateStore,
|
||||
IOptions<StellaOpsAuthorityOptions> authorityOptions,
|
||||
TimeProvider clock,
|
||||
ILogger<RevocationBundleBuilder> logger)
|
||||
{
|
||||
this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore));
|
||||
this.revocationStore = revocationStore ?? throw new ArgumentNullException(nameof(revocationStore));
|
||||
this.stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
|
||||
this.authorityOptions = authorityOptions?.Value ?? throw new ArgumentNullException(nameof(authorityOptions));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<RevocationBundleBuildResult> BuildAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var issuer = authorityOptions.Issuer?.ToString()?.TrimEnd('/')
|
||||
?? throw new InvalidOperationException("Authority issuer configuration is required before exporting revocations.");
|
||||
|
||||
var state = await stateStore.GetAsync(cancellationToken).ConfigureAwait(false);
|
||||
var previousSequence = state?.Sequence ?? 0;
|
||||
var sequence = previousSequence + 1;
|
||||
var issuedAt = clock.GetUtcNow();
|
||||
|
||||
var tokenDocuments = await tokenStore.ListRevokedAsync(null, cancellationToken).ConfigureAwait(false);
|
||||
var manualDocuments = await revocationStore.GetActiveAsync(issuedAt, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var entries = new List<RevocationEntryModel>();
|
||||
entries.AddRange(BuildTokenEntries(tokenDocuments, issuedAt));
|
||||
entries.AddRange(BuildManualEntries(manualDocuments));
|
||||
|
||||
entries.Sort(static (left, right) =>
|
||||
{
|
||||
var categoryCompare = string.CompareOrdinal(left.Category, right.Category);
|
||||
if (categoryCompare != 0)
|
||||
{
|
||||
return categoryCompare;
|
||||
}
|
||||
|
||||
var idCompare = string.CompareOrdinal(left.Id, right.Id);
|
||||
if (idCompare != 0)
|
||||
{
|
||||
return idCompare;
|
||||
}
|
||||
|
||||
return DateTimeOffset.Compare(left.RevokedAt, right.RevokedAt);
|
||||
});
|
||||
|
||||
var metadata = new SortedDictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["entryCount"] = entries.Count.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
var bundle = new RevocationBundleModel
|
||||
{
|
||||
SchemaVersion = SchemaVersion,
|
||||
Issuer = issuer,
|
||||
IssuedAt = issuedAt,
|
||||
ValidFrom = issuedAt,
|
||||
Sequence = sequence,
|
||||
SigningKeyId = authorityOptions.Signing?.ActiveKeyId,
|
||||
Revocations = entries,
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(bundle, SerializerOptions);
|
||||
var sha256 = Convert.ToHexString(SHA256.HashData(jsonBytes)).ToLowerInvariant();
|
||||
bundle.BundleId = sha256;
|
||||
|
||||
await PersistStateAsync(previousSequence, sequence, sha256, issuedAt, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new RevocationBundleBuildResult(bundle, jsonBytes, sha256, sequence, issuedAt);
|
||||
}
|
||||
|
||||
private IEnumerable<RevocationEntryModel> BuildTokenEntries(IReadOnlyCollection<AuthorityTokenDocument> documents, DateTimeOffset issuedAt)
|
||||
{
|
||||
foreach (var document in documents)
|
||||
{
|
||||
if (!string.Equals(document.Status, "revoked", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (document.ExpiresAt is { } expires && expires <= issuedAt)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var revocationId = document.TokenId;
|
||||
if (string.IsNullOrWhiteSpace(revocationId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var scopes = document.Scope.Count > 0
|
||||
? document.Scope
|
||||
.Where(scope => !string.IsNullOrWhiteSpace(scope))
|
||||
.Select(scope => scope.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(scope => scope, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
: null;
|
||||
|
||||
var metadata = document.RevokedMetadata is null
|
||||
? null
|
||||
: new SortedDictionary<string, string?>(document.RevokedMetadata, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
yield return new RevocationEntryModel
|
||||
{
|
||||
Id = revocationId,
|
||||
Category = "token",
|
||||
TokenType = document.Type,
|
||||
SubjectId = Normalize(document.SubjectId),
|
||||
ClientId = Normalize(document.ClientId),
|
||||
Reason = NormalizeReason(document.RevokedReason) ?? "unspecified",
|
||||
ReasonDescription = Normalize(document.RevokedReasonDescription),
|
||||
RevokedAt = document.RevokedAt ?? document.CreatedAt,
|
||||
EffectiveAt = document.RevokedAt ?? document.CreatedAt,
|
||||
ExpiresAt = document.ExpiresAt,
|
||||
Scopes = scopes,
|
||||
Metadata = metadata
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<RevocationEntryModel> BuildManualEntries(IReadOnlyCollection<AuthorityRevocationDocument> documents)
|
||||
{
|
||||
foreach (var document in documents)
|
||||
{
|
||||
var metadata = document.Metadata is null
|
||||
? null
|
||||
: new SortedDictionary<string, string?>(document.Metadata, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var scopes = document.Scopes is null
|
||||
? null
|
||||
: document.Scopes
|
||||
.Where(scope => !string.IsNullOrWhiteSpace(scope))
|
||||
.Select(scope => scope.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(scope => scope, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
yield return new RevocationEntryModel
|
||||
{
|
||||
Id = document.RevocationId,
|
||||
Category = document.Category,
|
||||
TokenType = Normalize(document.TokenType),
|
||||
SubjectId = Normalize(document.SubjectId),
|
||||
ClientId = Normalize(document.ClientId),
|
||||
Reason = NormalizeReason(document.Reason) ?? "unspecified",
|
||||
ReasonDescription = Normalize(document.ReasonDescription),
|
||||
RevokedAt = document.RevokedAt,
|
||||
EffectiveAt = document.EffectiveAt ?? document.RevokedAt,
|
||||
ExpiresAt = document.ExpiresAt,
|
||||
Scopes = scopes,
|
||||
Fingerprint = Normalize(document.Fingerprint),
|
||||
Metadata = metadata
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PersistStateAsync(long previousSequence, long newSequence, string bundleId, DateTimeOffset issuedAt, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await stateStore.UpdateAsync(previousSequence, newSequence, bundleId, issuedAt, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to update revocation export state (expected sequence {Expected}, new sequence {Sequence}).", previousSequence, newSequence);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string? NormalizeReason(string? reason)
|
||||
{
|
||||
var normalized = Normalize(reason);
|
||||
return normalized?.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Authority.Revocation;
|
||||
|
||||
internal sealed class RevocationBundleModel
|
||||
{
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string SchemaVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("issuer")]
|
||||
[JsonPropertyOrder(2)]
|
||||
public required string Issuer { get; init; }
|
||||
|
||||
[JsonPropertyName("bundleId")]
|
||||
[JsonPropertyOrder(3)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? BundleId { get; set; }
|
||||
|
||||
[JsonPropertyName("issuedAt")]
|
||||
[JsonPropertyOrder(4)]
|
||||
public required DateTimeOffset IssuedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("validFrom")]
|
||||
[JsonPropertyOrder(5)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? ValidFrom { get; init; }
|
||||
|
||||
[JsonPropertyName("expiresAt")]
|
||||
[JsonPropertyOrder(6)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
[JsonPropertyName("sequence")]
|
||||
[JsonPropertyOrder(7)]
|
||||
public required long Sequence { get; init; }
|
||||
|
||||
[JsonPropertyName("signingKeyId")]
|
||||
[JsonPropertyOrder(8)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? SigningKeyId { get; set; }
|
||||
|
||||
[JsonPropertyName("revocations")]
|
||||
[JsonPropertyOrder(9)]
|
||||
public required List<RevocationEntryModel> Revocations { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
[JsonPropertyOrder(10)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public SortedDictionary<string, string?>? Metadata { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace StellaOps.Authority.Revocation;
|
||||
|
||||
internal sealed record RevocationBundleSignature(string Algorithm, string KeyId, string Value);
|
||||
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Revocation;
|
||||
|
||||
internal sealed class RevocationBundleSigner
|
||||
{
|
||||
private static readonly JsonSerializerOptions HeaderSerializerOptions = new(JsonSerializerDefaults.General)
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly ICryptoProviderRegistry providerRegistry;
|
||||
private readonly StellaOpsAuthorityOptions authorityOptions;
|
||||
private readonly ILogger<RevocationBundleSigner> logger;
|
||||
|
||||
public RevocationBundleSigner(
|
||||
ICryptoProviderRegistry providerRegistry,
|
||||
IOptions<StellaOpsAuthorityOptions> authorityOptions,
|
||||
ILogger<RevocationBundleSigner> logger)
|
||||
{
|
||||
this.providerRegistry = providerRegistry ?? throw new ArgumentNullException(nameof(providerRegistry));
|
||||
this.authorityOptions = authorityOptions?.Value ?? throw new ArgumentNullException(nameof(authorityOptions));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<RevocationBundleSignature> SignAsync(byte[] payload, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
|
||||
var signing = authorityOptions.Signing ?? throw new InvalidOperationException("Authority signing configuration is required to export revocations.");
|
||||
if (string.IsNullOrWhiteSpace(signing.ActiveKeyId))
|
||||
{
|
||||
throw new InvalidOperationException("Authority signing configuration requires an active key identifier.");
|
||||
}
|
||||
|
||||
var algorithm = string.IsNullOrWhiteSpace(signing.Algorithm)
|
||||
? SignatureAlgorithms.Es256
|
||||
: signing.Algorithm.Trim();
|
||||
|
||||
var keyReference = new CryptoKeyReference(signing.ActiveKeyId, signing.Provider);
|
||||
var signer = providerRegistry.ResolveSigner(CryptoCapability.Signing, algorithm, keyReference, signing.Provider);
|
||||
|
||||
var header = new Dictionary<string, object>
|
||||
{
|
||||
["alg"] = algorithm,
|
||||
["kid"] = signing.ActiveKeyId,
|
||||
["typ"] = "application/vnd.stellaops.revocation-bundle+jws",
|
||||
["b64"] = false,
|
||||
["crit"] = new[] { "b64" }
|
||||
};
|
||||
|
||||
var headerJson = JsonSerializer.Serialize(header, HeaderSerializerOptions);
|
||||
var protectedHeader = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson));
|
||||
|
||||
var signingInputLength = protectedHeader.Length + 1 + payload.Length;
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(signingInputLength);
|
||||
try
|
||||
{
|
||||
var headerBytes = Encoding.ASCII.GetBytes(protectedHeader);
|
||||
Buffer.BlockCopy(headerBytes, 0, buffer, 0, headerBytes.Length);
|
||||
buffer[headerBytes.Length] = (byte)'.';
|
||||
Buffer.BlockCopy(payload, 0, buffer, headerBytes.Length + 1, payload.Length);
|
||||
|
||||
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));
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Base64UrlEncode(ReadOnlySpan<byte> value)
|
||||
{
|
||||
var encoded = Convert.ToBase64String(value);
|
||||
var builder = new StringBuilder(encoded.Length);
|
||||
foreach (var ch in encoded)
|
||||
{
|
||||
switch (ch)
|
||||
{
|
||||
case '+':
|
||||
builder.Append('-');
|
||||
break;
|
||||
case '/':
|
||||
builder.Append('_');
|
||||
break;
|
||||
case '=':
|
||||
break;
|
||||
default:
|
||||
builder.Append(ch);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Authority.Revocation;
|
||||
|
||||
internal sealed class RevocationEntryModel
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("category")]
|
||||
[JsonPropertyOrder(2)]
|
||||
public required string Category { get; init; }
|
||||
|
||||
[JsonPropertyName("tokenType")]
|
||||
[JsonPropertyOrder(3)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? TokenType { get; init; }
|
||||
|
||||
[JsonPropertyName("subjectId")]
|
||||
[JsonPropertyOrder(4)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? SubjectId { get; init; }
|
||||
|
||||
[JsonPropertyName("clientId")]
|
||||
[JsonPropertyOrder(5)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ClientId { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
[JsonPropertyOrder(6)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
[JsonPropertyName("reasonDescription")]
|
||||
[JsonPropertyOrder(7)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ReasonDescription { get; init; }
|
||||
|
||||
[JsonPropertyName("revokedAt")]
|
||||
[JsonPropertyOrder(8)]
|
||||
public DateTimeOffset RevokedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("effectiveAt")]
|
||||
[JsonPropertyOrder(9)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? EffectiveAt { get; init; }
|
||||
|
||||
[JsonPropertyName("expiresAt")]
|
||||
[JsonPropertyOrder(10)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
[JsonPropertyName("scopes")]
|
||||
[JsonPropertyOrder(11)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public List<string>? Scopes { get; init; }
|
||||
|
||||
[JsonPropertyName("fingerprint")]
|
||||
[JsonPropertyOrder(12)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Fingerprint { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
[JsonPropertyOrder(13)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public SortedDictionary<string, string?>? Metadata { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace StellaOps.Authority.Revocation;
|
||||
|
||||
internal sealed record RevocationExportPackage(
|
||||
RevocationBundleBuildResult Bundle,
|
||||
RevocationBundleSignature Signature);
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Authority.Revocation;
|
||||
|
||||
internal sealed class RevocationExportResponse
|
||||
{
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public required string SchemaVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("bundleId")]
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
[JsonPropertyName("sequence")]
|
||||
public required long Sequence { get; init; }
|
||||
|
||||
[JsonPropertyName("issuedAt")]
|
||||
public required DateTimeOffset IssuedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("signingKeyId")]
|
||||
public string? SigningKeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("bundle")]
|
||||
public required RevocationExportPayload Bundle { get; init; }
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public required RevocationExportSignature Signature { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public required RevocationExportDigest Digest { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class RevocationExportPayload
|
||||
{
|
||||
[JsonPropertyName("fileName")]
|
||||
public string FileName { get; init; } = "revocation-bundle.json";
|
||||
|
||||
[JsonPropertyName("contentType")]
|
||||
public string ContentType { get; init; } = "application/json";
|
||||
|
||||
[JsonPropertyName("encoding")]
|
||||
public string Encoding { get; init; } = "base64";
|
||||
|
||||
[JsonPropertyName("data")]
|
||||
public required string Data { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class RevocationExportSignature
|
||||
{
|
||||
[JsonPropertyName("fileName")]
|
||||
public string FileName { get; init; } = "revocation-bundle.json.jws";
|
||||
|
||||
[JsonPropertyName("algorithm")]
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public required string Value { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class RevocationExportDigest
|
||||
{
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = "sha256";
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public required string Value { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Signing;
|
||||
|
||||
internal sealed class AuthorityJwksService
|
||||
{
|
||||
private readonly ICryptoProviderRegistry registry;
|
||||
private readonly ILogger<AuthorityJwksService> logger;
|
||||
|
||||
public AuthorityJwksService(ICryptoProviderRegistry registry, ILogger<AuthorityJwksService> logger)
|
||||
{
|
||||
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public AuthorityJwksResponse Build() => new(BuildKeys());
|
||||
|
||||
private IReadOnlyCollection<JwksKeyEntry> BuildKeys()
|
||||
{
|
||||
var keys = new List<JwksKeyEntry>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var provider in registry.Providers)
|
||||
{
|
||||
foreach (var signingKey in provider.GetSigningKeys())
|
||||
{
|
||||
var keyId = signingKey.Reference.KeyId;
|
||||
if (!seen.Add(keyId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var signer = provider.GetSigner(signingKey.AlgorithmId, signingKey.Reference);
|
||||
var jwk = signer.ExportPublicJsonWebKey();
|
||||
var entry = new JwksKeyEntry
|
||||
{
|
||||
Kid = jwk.Kid,
|
||||
Kty = jwk.Kty,
|
||||
Use = string.IsNullOrWhiteSpace(jwk.Use) ? "sig" : jwk.Use,
|
||||
Alg = jwk.Alg,
|
||||
Crv = jwk.Crv,
|
||||
X = jwk.X,
|
||||
Y = jwk.Y,
|
||||
Status = signingKey.Metadata.TryGetValue("status", out var status) ? status : "active"
|
||||
};
|
||||
keys.Add(entry);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to export JWKS entry for key {KeyId}.", keyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record AuthorityJwksResponse([property: JsonPropertyName("keys")] IReadOnlyCollection<JwksKeyEntry> Keys);
|
||||
|
||||
internal sealed class JwksKeyEntry
|
||||
{
|
||||
[JsonPropertyName("kty")]
|
||||
public string? Kty { get; set; }
|
||||
|
||||
[JsonPropertyName("use")]
|
||||
public string? Use { get; set; }
|
||||
|
||||
[JsonPropertyName("kid")]
|
||||
public string? Kid { get; set; }
|
||||
|
||||
[JsonPropertyName("alg")]
|
||||
public string? Alg { get; set; }
|
||||
|
||||
[JsonPropertyName("crv")]
|
||||
public string? Crv { get; set; }
|
||||
|
||||
[JsonPropertyName("x")]
|
||||
public string? X { get; set; }
|
||||
|
||||
[JsonPropertyName("y")]
|
||||
public string? Y { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Signing;
|
||||
|
||||
internal sealed class AuthoritySigningKeyManager
|
||||
{
|
||||
private readonly object syncRoot = new();
|
||||
private readonly ICryptoProviderRegistry registry;
|
||||
private readonly IReadOnlyList<IAuthoritySigningKeySource> keySources;
|
||||
private readonly StellaOpsAuthorityOptions authorityOptions;
|
||||
private readonly string basePath;
|
||||
private readonly ILogger<AuthoritySigningKeyManager> logger;
|
||||
private RegisteredSigningKey? activeKey;
|
||||
private readonly Dictionary<string, RegisteredSigningKey> retiredKeys = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public AuthoritySigningKeyManager(
|
||||
ICryptoProviderRegistry registry,
|
||||
IEnumerable<IAuthoritySigningKeySource> keySources,
|
||||
IOptions<StellaOpsAuthorityOptions> authorityOptions,
|
||||
IHostEnvironment environment,
|
||||
ILogger<AuthoritySigningKeyManager> logger)
|
||||
{
|
||||
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
if (keySources is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(keySources));
|
||||
}
|
||||
|
||||
this.keySources = keySources.ToArray();
|
||||
if (this.keySources.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("At least one Authority signing key source must be registered.");
|
||||
}
|
||||
|
||||
this.authorityOptions = authorityOptions?.Value ?? throw new ArgumentNullException(nameof(authorityOptions));
|
||||
basePath = environment?.ContentRootPath ?? throw new ArgumentNullException(nameof(environment));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
LoadInitialKeys();
|
||||
}
|
||||
|
||||
public SigningRotationResult Rotate(SigningRotationRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
lock (syncRoot)
|
||||
{
|
||||
var signing = authorityOptions.Signing ?? throw new InvalidOperationException("Authority signing configuration is not available.");
|
||||
if (!signing.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Signing is disabled. Enable signing before rotating keys.");
|
||||
}
|
||||
|
||||
var keyId = (request.KeyId ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
throw new InvalidOperationException("Rotation requires a keyId.");
|
||||
}
|
||||
|
||||
var location = request.Location?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(location))
|
||||
{
|
||||
throw new InvalidOperationException("Rotation requires a keyPath/location for the new signing key.");
|
||||
}
|
||||
|
||||
var algorithm = NormaliseAlgorithm(string.IsNullOrWhiteSpace(request.Algorithm)
|
||||
? signing.Algorithm
|
||||
: request.Algorithm);
|
||||
var source = NormaliseSource(string.IsNullOrWhiteSpace(request.Source)
|
||||
? signing.KeySource
|
||||
: request.Source);
|
||||
var providerName = NormaliseProviderName(request.Provider ?? signing.Provider);
|
||||
|
||||
IReadOnlyDictionary<string, string?>? metadata = null;
|
||||
if (request.Metadata is not null && request.Metadata.Count > 0)
|
||||
{
|
||||
metadata = new Dictionary<string, string?>(request.Metadata, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var provider = ResolveProvider(providerName, algorithm);
|
||||
var loader = ResolveSource(source);
|
||||
var loadRequest = new AuthoritySigningKeyRequest(
|
||||
keyId,
|
||||
algorithm,
|
||||
source,
|
||||
location,
|
||||
AuthoritySigningKeyStatus.Active,
|
||||
basePath,
|
||||
provider.Name,
|
||||
additionalMetadata: metadata);
|
||||
var newKey = loader.Load(loadRequest);
|
||||
provider.UpsertSigningKey(newKey);
|
||||
|
||||
if (retiredKeys.Remove(keyId))
|
||||
{
|
||||
logger.LogInformation("Promoted retired signing key {KeyId} to active status.", keyId);
|
||||
}
|
||||
|
||||
string? previousKeyId = null;
|
||||
if (activeKey is not null)
|
||||
{
|
||||
previousKeyId = activeKey.Key.Reference.KeyId;
|
||||
if (!string.Equals(previousKeyId, keyId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
RetireCurrentActive();
|
||||
}
|
||||
}
|
||||
|
||||
activeKey = new RegisteredSigningKey(newKey, provider.Name, source, location);
|
||||
signing.ActiveKeyId = keyId;
|
||||
signing.KeyPath = location;
|
||||
signing.KeySource = source;
|
||||
signing.Provider = provider.Name;
|
||||
|
||||
RemoveAdditionalOption(keyId);
|
||||
|
||||
logger.LogInformation("Authority signing key rotated. Active key is now {KeyId} via provider {Provider}.", keyId, provider.Name);
|
||||
|
||||
return new SigningRotationResult(
|
||||
keyId,
|
||||
provider.Name,
|
||||
source,
|
||||
location,
|
||||
previousKeyId,
|
||||
retiredKeys.Keys.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
public SigningKeySnapshot Snapshot
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
var active = activeKey;
|
||||
return new SigningKeySnapshot(
|
||||
active?.Key.Reference.KeyId,
|
||||
active?.ProviderName,
|
||||
active?.Source,
|
||||
active?.Location,
|
||||
retiredKeys.Values
|
||||
.Select(static registration => new SigningKeySnapshot.RetiredKey(
|
||||
registration.Key.Reference.KeyId,
|
||||
registration.ProviderName,
|
||||
registration.Source,
|
||||
registration.Location))
|
||||
.ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadInitialKeys()
|
||||
{
|
||||
var signing = authorityOptions.Signing;
|
||||
if (signing is null || !signing.Enabled)
|
||||
{
|
||||
logger.LogInformation("Authority signing is disabled; JWKS will expose ephemeral keys until signing is enabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
var algorithm = NormaliseAlgorithm(signing.Algorithm);
|
||||
var source = NormaliseSource(signing.KeySource);
|
||||
var activeRequest = new AuthoritySigningKeyRequest(
|
||||
signing.ActiveKeyId,
|
||||
algorithm,
|
||||
source,
|
||||
signing.KeyPath,
|
||||
AuthoritySigningKeyStatus.Active,
|
||||
basePath,
|
||||
NormaliseProviderName(signing.Provider));
|
||||
activeKey = LoadAndRegister(activeRequest);
|
||||
signing.KeySource = source;
|
||||
signing.Provider = activeKey.ProviderName;
|
||||
|
||||
foreach (var additional in signing.AdditionalKeys)
|
||||
{
|
||||
var keyId = (additional.KeyId ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
logger.LogWarning("Skipped additional signing key with empty keyId.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(keyId, activeKey.Key.Reference.KeyId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var additionalLocation = additional.Path?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(additionalLocation))
|
||||
{
|
||||
logger.LogWarning("Additional signing key {KeyId} is missing a path. Skipping.", keyId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var additionalSource = NormaliseSource(additional.Source ?? source);
|
||||
var request = new AuthoritySigningKeyRequest(
|
||||
keyId,
|
||||
algorithm,
|
||||
additionalSource,
|
||||
additionalLocation,
|
||||
AuthoritySigningKeyStatus.Retired,
|
||||
basePath,
|
||||
NormaliseProviderName(signing.Provider));
|
||||
|
||||
try
|
||||
{
|
||||
var registration = LoadAndRegister(request);
|
||||
retiredKeys[registration.Key.Reference.KeyId] = registration;
|
||||
additional.Source = additionalSource;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to load retired signing key {KeyId}. It will be ignored for JWKS responses.", keyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RetireCurrentActive()
|
||||
{
|
||||
if (activeKey is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var previous = activeKey;
|
||||
var metadata = new Dictionary<string, string?>(previous.Key.Metadata, StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["status"] = AuthoritySigningKeyStatus.Retired
|
||||
};
|
||||
|
||||
var retiredKey = new CryptoSigningKey(
|
||||
previous.Key.Reference,
|
||||
previous.Key.AlgorithmId,
|
||||
in previous.Key.PrivateParameters,
|
||||
previous.Key.CreatedAt,
|
||||
previous.Key.ExpiresAt,
|
||||
metadata);
|
||||
|
||||
var provider = ResolveProvider(previous.ProviderName, retiredKey.AlgorithmId);
|
||||
provider.UpsertSigningKey(retiredKey);
|
||||
|
||||
var registration = new RegisteredSigningKey(retiredKey, provider.Name, previous.Source, previous.Location);
|
||||
retiredKeys[registration.Key.Reference.KeyId] = registration;
|
||||
UpsertAdditionalOption(registration);
|
||||
|
||||
logger.LogInformation("Moved signing key {KeyId} to retired set (provider {Provider}).", registration.Key.Reference.KeyId, provider.Name);
|
||||
}
|
||||
|
||||
private RegisteredSigningKey LoadAndRegister(AuthoritySigningKeyRequest request)
|
||||
{
|
||||
var source = ResolveSource(request.Source);
|
||||
var provider = ResolveProvider(request.Provider, request.Algorithm);
|
||||
var key = source.Load(request);
|
||||
provider.UpsertSigningKey(key);
|
||||
|
||||
logger.LogDebug("Loaded signing key {KeyId} (status {Status}) via provider {Provider}.", key.Reference.KeyId, request.Status, provider.Name);
|
||||
|
||||
return new RegisteredSigningKey(key, provider.Name, request.Source, request.Location);
|
||||
}
|
||||
|
||||
private IAuthoritySigningKeySource ResolveSource(string source)
|
||||
{
|
||||
foreach (var loader in keySources)
|
||||
{
|
||||
if (loader.CanLoad(source))
|
||||
{
|
||||
return loader;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"No signing key source registered for '{source}'.");
|
||||
}
|
||||
|
||||
private ICryptoProvider ResolveProvider(string? preferredProvider, string algorithm)
|
||||
{
|
||||
var normalised = NormaliseProviderName(preferredProvider);
|
||||
if (!string.IsNullOrWhiteSpace(normalised) &&
|
||||
registry.TryResolve(normalised!, out var provider) &&
|
||||
provider.Supports(CryptoCapability.Signing, algorithm))
|
||||
{
|
||||
return provider;
|
||||
}
|
||||
|
||||
return registry.ResolveOrThrow(CryptoCapability.Signing, algorithm);
|
||||
}
|
||||
|
||||
private void UpsertAdditionalOption(RegisteredSigningKey registration)
|
||||
{
|
||||
var additional = authorityOptions.Signing.AdditionalKeys;
|
||||
for (var index = 0; index < additional.Count; index++)
|
||||
{
|
||||
var entry = additional[index];
|
||||
if (string.Equals(entry.KeyId, registration.Key.Reference.KeyId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
entry.Path = registration.Location;
|
||||
entry.Source = registration.Source;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
additional.Add(new AuthoritySigningAdditionalKeyOptions
|
||||
{
|
||||
KeyId = registration.Key.Reference.KeyId,
|
||||
Path = registration.Location,
|
||||
Source = registration.Source
|
||||
});
|
||||
}
|
||||
|
||||
private void RemoveAdditionalOption(string keyId)
|
||||
{
|
||||
var additional = authorityOptions.Signing.AdditionalKeys;
|
||||
for (var index = additional.Count - 1; index >= 0; index--)
|
||||
{
|
||||
if (string.Equals(additional[index].KeyId, keyId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
additional.RemoveAt(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormaliseAlgorithm(string? algorithm)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(algorithm)
|
||||
? SignatureAlgorithms.Es256
|
||||
: algorithm.Trim();
|
||||
}
|
||||
|
||||
private static string NormaliseSource(string? source)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(source) ? "file" : source.Trim();
|
||||
}
|
||||
|
||||
private static string? NormaliseProviderName(string? provider)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(provider) ? null : provider.Trim();
|
||||
}
|
||||
|
||||
private sealed record RegisteredSigningKey(
|
||||
CryptoSigningKey Key,
|
||||
string ProviderName,
|
||||
string Source,
|
||||
string Location);
|
||||
}
|
||||
|
||||
internal sealed record SigningRotationResult(
|
||||
string ActiveKeyId,
|
||||
string ActiveProvider,
|
||||
string ActiveSource,
|
||||
string ActiveLocation,
|
||||
string? PreviousKeyId,
|
||||
IReadOnlyCollection<string> RetiredKeyIds);
|
||||
|
||||
internal sealed class SigningKeySnapshot
|
||||
{
|
||||
public SigningKeySnapshot(
|
||||
string? activeKeyId,
|
||||
string? activeProvider,
|
||||
string? activeSource,
|
||||
string? activeLocation,
|
||||
IReadOnlyCollection<RetiredKey> retired)
|
||||
{
|
||||
ActiveKeyId = activeKeyId;
|
||||
ActiveProvider = activeProvider;
|
||||
ActiveSource = activeSource;
|
||||
ActiveLocation = activeLocation;
|
||||
Retired = retired ?? Array.Empty<RetiredKey>();
|
||||
}
|
||||
|
||||
public string? ActiveKeyId { get; }
|
||||
|
||||
public string? ActiveProvider { get; }
|
||||
|
||||
public string? ActiveSource { get; }
|
||||
|
||||
public string? ActiveLocation { get; }
|
||||
|
||||
public IReadOnlyCollection<RetiredKey> Retired { get; }
|
||||
|
||||
public sealed record RetiredKey(
|
||||
string KeyId,
|
||||
string Provider,
|
||||
string Source,
|
||||
string Location);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Authority.Signing;
|
||||
|
||||
internal sealed class AuthoritySigningKeyRequest
|
||||
{
|
||||
public AuthoritySigningKeyRequest(
|
||||
string keyId,
|
||||
string algorithm,
|
||||
string source,
|
||||
string location,
|
||||
string status,
|
||||
string basePath,
|
||||
string? provider = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
DateTimeOffset? expiresAt = null,
|
||||
IReadOnlyDictionary<string, string?>? additionalMetadata = null)
|
||||
{
|
||||
KeyId = keyId ?? throw new ArgumentNullException(nameof(keyId));
|
||||
Algorithm = string.IsNullOrWhiteSpace(algorithm)
|
||||
? throw new ArgumentException("Algorithm identifier is required.", nameof(algorithm))
|
||||
: algorithm;
|
||||
Source = string.IsNullOrWhiteSpace(source)
|
||||
? throw new ArgumentException("Signing key source is required.", nameof(source))
|
||||
: source;
|
||||
Location = location ?? throw new ArgumentNullException(nameof(location));
|
||||
Status = string.IsNullOrWhiteSpace(status)
|
||||
? throw new ArgumentException("Signing key status is required.", nameof(status))
|
||||
: status;
|
||||
BasePath = basePath ?? throw new ArgumentNullException(nameof(basePath));
|
||||
Provider = provider;
|
||||
CreatedAt = createdAt;
|
||||
ExpiresAt = expiresAt;
|
||||
AdditionalMetadata = additionalMetadata;
|
||||
}
|
||||
|
||||
public string KeyId { get; }
|
||||
|
||||
public string Algorithm { get; }
|
||||
|
||||
public string Source { get; }
|
||||
|
||||
public string Location { get; }
|
||||
|
||||
public string Status { get; }
|
||||
|
||||
public string BasePath { get; }
|
||||
|
||||
public string? Provider { get; }
|
||||
|
||||
public DateTimeOffset? CreatedAt { get; }
|
||||
|
||||
public DateTimeOffset? ExpiresAt { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string?>? AdditionalMetadata { get; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.Authority.Signing;
|
||||
|
||||
internal static class AuthoritySigningKeyStatus
|
||||
{
|
||||
public const string Active = "active";
|
||||
public const string Retired = "retired";
|
||||
public const string Disabled = "disabled";
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Signing;
|
||||
|
||||
internal sealed class FileAuthoritySigningKeySource : IAuthoritySigningKeySource
|
||||
{
|
||||
private readonly ILogger<FileAuthoritySigningKeySource> logger;
|
||||
|
||||
public FileAuthoritySigningKeySource(ILogger<FileAuthoritySigningKeySource> logger)
|
||||
{
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public bool CanLoad(string source)
|
||||
=> string.Equals(source, "file", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public CryptoSigningKey Load(AuthoritySigningKeyRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var path = ResolvePath(request.BasePath, request.Location);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException($"Authority signing key '{request.KeyId}' not found.", path);
|
||||
}
|
||||
|
||||
var pem = File.ReadAllText(path);
|
||||
|
||||
using var ecdsa = ECDsa.Create();
|
||||
try
|
||||
{
|
||||
ecdsa.ImportFromPem(pem);
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to load Authority signing key {KeyId} from {Path}.", request.KeyId, path);
|
||||
throw new InvalidOperationException("Failed to import Authority signing key. Ensure the PEM is an unencrypted EC private key.", ex);
|
||||
}
|
||||
|
||||
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
|
||||
|
||||
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["source"] = Path.GetFullPath(path),
|
||||
["loader"] = "file",
|
||||
["status"] = request.Status
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Provider))
|
||||
{
|
||||
metadata["provider"] = request.Provider;
|
||||
}
|
||||
|
||||
if (request.AdditionalMetadata is not null)
|
||||
{
|
||||
foreach (var pair in request.AdditionalMetadata)
|
||||
{
|
||||
metadata[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
metadata["status"] = request.Status;
|
||||
|
||||
logger.LogInformation("Loaded Authority signing key {KeyId} from {Path}.", request.KeyId, path);
|
||||
|
||||
return new CryptoSigningKey(
|
||||
new CryptoKeyReference(request.KeyId, request.Provider),
|
||||
request.Algorithm,
|
||||
in parameters,
|
||||
request.CreatedAt ?? DateTimeOffset.UtcNow,
|
||||
request.ExpiresAt,
|
||||
metadata);
|
||||
}
|
||||
|
||||
private static string ResolvePath(string basePath, string location)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(location))
|
||||
{
|
||||
throw new InvalidOperationException("Signing key location is required.");
|
||||
}
|
||||
|
||||
if (Path.IsPathRooted(location))
|
||||
{
|
||||
return location;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(basePath))
|
||||
{
|
||||
return Path.GetFullPath(location);
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(basePath, location));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Signing;
|
||||
|
||||
internal interface IAuthoritySigningKeySource
|
||||
{
|
||||
bool CanLoad(string source);
|
||||
|
||||
CryptoSigningKey Load(AuthoritySigningKeyRequest request);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Authority.Signing;
|
||||
|
||||
public sealed class SigningRotationRequest
|
||||
{
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
|
||||
public string? Source { get; set; }
|
||||
|
||||
public string? Location { get; set; }
|
||||
|
||||
public string? Algorithm { get; set; }
|
||||
|
||||
public string? Provider { get; set; }
|
||||
|
||||
public Dictionary<string, string?>? Metadata { get; set; }
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| CORE5B.DOC | TODO | Authority Core, Docs Guild | CORE5 | Document token persistence, revocation semantics, and enrichment expectations for resource servers/plugins. | ✅ `docs/11_AUTHORITY.md` + plugin guide updated with claims + token store notes; ✅ Samples include revocation sync guidance. |
|
||||
| CORE9.REVOCATION | TODO | 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 | TODO | 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 | BLOCKED (Team 2) | 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. |
|
||||
| CORE5B.DOC | DONE (2025-10-12) | Authority Core, Docs Guild | CORE5 | Document token persistence, revocation semantics, and enrichment expectations for resource servers/plugins. | ✅ `docs/11_AUTHORITY.md` + plugin guide updated with claims + token store notes; ✅ Samples include revocation sync guidance. |
|
||||
| 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. |
|
||||
| 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 | TODO | 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 | TODO | Security Guild, DevOps | SEC4.HOST | Integrate signing keys with provider registry (initial ES256). | ✅ Keys loaded via `ICryptoProvider` signer; ✅ Rotation SOP documented. |
|
||||
| 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. |
|
||||
| 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). |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user